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

diff --git a/src/index.html b/src/index.html index ea7cd5d..5518e22 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,7 @@ Diamond File Converter + - + -
+
-
- -

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

-
- - -
-
-
- -
-
-
- -
-

Convert to:

-
-
- -
-
-
-

-
-
-
-
- - sync - Converting... - - 0% + +
+ checklist + +
+ + + +
+ +
-
-
-
-
- check_circle - + +
+
+
+ + +
+
+
+ + +
+
+
-
-
-
- -
+
+ + + +
+
+
+ + + diff --git a/src/main.js b/src/main.js index b58b682..920a83a 100644 --- a/src/main.js +++ b/src/main.js @@ -341,15 +341,11 @@ ipcMain.handle(IPC.JOB_RUN, async (_event, spec) => { const result = await runJobToCompletion(job); + // Failures are reported on the card and in a toast, not as a blocking + // dialog. v1 opened one modal per failed file mid-queue, which with a + // concurrency pool would stack several at once and halt the whole batch. if (result.status === STATUS.ERROR) { log(LOG.ERROR, `Conversion failed for ${job.inputPath}: ${result.error}`); - dialog.showMessageBox(mainWindow, { - type: "error", - title: "Conversion Failed", - message: `Could not convert ${path.basename(job.inputPath)}.`, - detail: result.error ?? "ffmpeg did not report a reason.", - buttons: ["OK"], - }); } return result; @@ -403,6 +399,11 @@ ipcMain.handle(IPC.APP_GET_FORMATS, () => ({ conversionMap: formats.buildLegacyConversionMap(), aliases: formats.EXT_ALIASES, supported: formats.SUPPORTED_EXTENSIONS, + // The full capability graph, including cross-kind targets. core/display.js + // takes this as an argument because the renderer cannot require formats.js. + targetsByExt: Object.fromEntries( + formats.SUPPORTED_EXTENSIONS.map(ext => [ext, formats.targetsFor(ext)]) + ), })); ipcMain.handle(IPC.SETTINGS_GET, () => store.get("settings")); diff --git a/src/renderer.js b/src/renderer.js index 505347a..b60f8e9 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1,429 +1,652 @@ -// Renderer. +// Renderer — job card grid. // -// Runs with contextIsolation on and no Node access. Everything that used to be -// a require() now goes through window.electronAPI / window.pathAPI, exposed by -// preload.js. +// Runs with contextIsolation on and no Node access. Everything reaches the main +// process through window.electronAPI, exposed by preload.js. +// +// The v1 UI was a single-column list with one shared target format per media +// kind and a strictly sequential convert loop. This is a grid of independently +// configurable job cards that run through the runner's concurrency pool. const api = window.electronAPI; const p = window.pathAPI; -let jobs = []; -// each job: { id, filePath, ext, type, targetExt, status, progress, outputPath, isDirectory, fileCount, error } -// status: "pending" | "converting" | "done" | "error" | "cancelled" - -let selectedFormats = {}; -// keyed by type: { audio: "mp3", video: null, image: "jpg" } - -let cancelRequested = false; -let activeJobId = null; +// -- State ------------------------------------------------------------------- -// Populated from the main process at startup, so the format graph has a single -// source of truth instead of being duplicated on both sides of the bridge. -let CONVERSION_MAP = {}; -let EXT_ALIASES = {}; +/** @type {Array<{id,filePath,ext,kind,targetExt,meta,status,progress,error,outputPath,isDirectory,fileCount}>} */ +let jobs = []; +let selection = new Set(); +let selectionAnchor = null; +let converting = false; -const actionAreas = ["convert", "progress", "done"]; +// Loaded as a plain script by index.html — the renderer has no Node access, +// and functions cannot cross the IPC bridge, so it cannot be required. +const display = window.display; -function generateId() { - return Math.random().toString(36).slice(2, 9); -} +// Populated from main at startup so the format graph has one source of truth. +let FORMATS = null; +let TARGETS = null; -const TYPE_ORDER = { video: 0, audio: 1, image: 2 }; -const TYPE_ICONS = { audio: "audio_file", video: "video_file", image: "image" }; +const el = {}; -function sortJobs() { - jobs.sort((a, b) => { - const byType = (TYPE_ORDER[a.type] ?? 99) - (TYPE_ORDER[b.type] ?? 99); - if (byType !== 0) return byType; - const byExt = a.ext.localeCompare(b.ext); - if (byExt !== 0) return byExt; - return p.basename(a.filePath).localeCompare(p.basename(b.filePath), undefined, { sensitivity: "base" }); - }); +function $(id) { + if (!el[id]) el[id] = document.getElementById(id); + return el[id]; } -function showAction(name) { - for (const a of actionAreas) { - document.getElementById(`action-${a}`).classList.toggle("d-none", a !== name); - } +function generateId() { + return `c${Math.random().toString(36).slice(2, 9)}`; } -function showState(name) { - const isEmpty = name === "empty"; - const isQueue = name === "queue"; - const showDropZone = isEmpty || isQueue; - - document.getElementById("drop-zone").classList.toggle("d-none", !showDropZone); - document.getElementById("dz-empty").classList.toggle("d-none", !isEmpty); - document.getElementById("dz-has-files").classList.toggle("d-none", !isQueue); - document.getElementById("file-list-section").classList.toggle("d-none", !isQueue); - document.getElementById("format-section").classList.toggle("d-none", !isQueue); - - if (isQueue) { - showAction("convert"); - } else if (name === "converting") { - showAction("progress"); - } else if (name === "done") { - showAction("done"); - } else { - for (const a of actionAreas) { - document.getElementById(`action-${a}`).classList.add("d-none"); - } - } -} +// -- Ingest ------------------------------------------------------------------ function addFiles(filePaths) { let added = 0; + let rejected = 0; + for (const filePath of filePaths) { const raw = p.extname(filePath).slice(1).toLowerCase(); - const ext = EXT_ALIASES[raw] ?? raw; - const entry = CONVERSION_MAP[ext]; - if (!entry) continue; - if (jobs.some(j => j.filePath === filePath)) continue; // skip duplicates - jobs.push({ id: generateId(), filePath, ext, type: entry.type, targetExt: null, status: "pending", progress: 0, outputPath: null }); + const ext = FORMATS.aliases[raw] ?? raw; + const entry = FORMATS.conversionMap[ext]; + + if (!entry) { rejected++; continue; } + if (jobs.some(j => j.filePath === filePath)) continue; + + jobs.push({ + id: generateId(), + filePath, + ext, + kind: entry.type, + targetExt: null, + meta: null, + status: "pending", + progress: 0, + error: null, + outputPath: null, + isDirectory: false, + fileCount: 0, + }); added++; } + if (added > 0) { - sortJobs(); - renderFileList(); - renderFormatGrid(); - updateConvertButton(); - showState("queue"); + render(); + probeNewJobs(); + } + + // v1 discarded unsupported files in silence, which read as the app being + // broken. Say so. + if (rejected > 0) { + toast( + `${rejected} ${rejected === 1 ? "file was" : "files were"} skipped — not a supported format.`, + added > 0 ? "warning" : "danger" + ); } + return added; } -/** - * Expand whatever was dropped or picked into a flat list of media files. - * Directory walking happens in the main process now: v1 did a synchronous, - * one-level-deep readdirSync on the UI thread and silently discarded anything - * nested. - */ +/** Expand folders in the main process, then queue whatever came back. */ async function ingestPaths(inputPaths) { - if (inputPaths.length === 0) return; + if (!inputPaths || inputPaths.length === 0) return; try { const result = await api.scanPaths(inputPaths, {}); - addFiles(result.files); + const added = addFiles(result.files); + + if (result.truncated) { + toast(`Stopped after ${result.files.length} files — the folder is very large.`, "warning"); + } else if (added > 0 && result.skipped > 0) { + toast(`Added ${added} ${added === 1 ? "file" : "files"}, skipped ${result.skipped} unsupported.`, "info"); + } else if (added === 0 && result.files.length === 0 && result.skipped > 0) { + toast("No supported media found in that folder.", "warning"); + } + for (const err of result.errors ?? []) { + toast(`Could not read ${p.basename(err.path)}: ${err.error}`, "danger"); + } } catch (_) { - // Fall back to treating them as plain files rather than dropping the - // interaction entirely. addFiles(inputPaths); } } -function removeJob(id) { - jobs = jobs.filter(j => j.id !== id); - if (jobs.length === 0) { - selectedFormats = {}; - showState("empty"); - } else { - renderFileList(); - renderFormatGrid(); - updateConvertButton(); +/** + * Probe anything that has not been probed yet. Cards are already on screen by + * this point — metadata only enriches them, so a slow or failing probe never + * blocks the UI. + */ +async function probeNewJobs() { + const pending = jobs.filter(j => j.meta === null); + for (const job of pending) { + job.meta = { pending: true }; + api.probeFile(job.filePath).then((meta) => { + const current = jobs.find(j => j.id === job.id); + if (!current) return; + current.meta = meta; + updateCard(current); + }).catch(() => { + const current = jobs.find(j => j.id === job.id); + if (current) { current.meta = { ok: false }; updateCard(current); } + }); } } -function renderFileList() { - const container = document.getElementById("file-list"); - container.innerHTML = ""; +// -- Selection --------------------------------------------------------------- - const count = jobs.length; - document.getElementById("dz-file-count").textContent = `${count} ${count !== 1 ? "Files" : "File"} Queued`; +function orderedIds() { + return jobs.map(j => j.id); +} - let prevType = null; - for (const job of jobs) { - if (prevType !== null && job.type !== prevType) { - const lastRow = container.lastElementChild; - if (lastRow) lastRow.style.borderBottom = "none"; - const divider = document.createElement("div"); - divider.className = "file-group-divider"; - container.appendChild(divider); - } - prevType = job.type; - - const row = document.createElement("div"); - row.className = "file-row"; - - const icon = document.createElement("span"); - icon.className = "material-icons-round text-secondary"; - icon.textContent = TYPE_ICONS[job.type] ?? "insert_drive_file"; - - const name = document.createElement("span"); - name.className = "file-row-name small"; - name.textContent = p.basename(job.filePath); - name.title = job.filePath; - - const removeBtn = document.createElement("button"); - removeBtn.className = "file-remove-btn"; - const removeIcon = document.createElement("span"); - removeIcon.className = "material-icons-round"; - removeIcon.textContent = "close"; - removeBtn.appendChild(removeIcon); - removeBtn.addEventListener("click", () => removeJob(job.id)); - - row.appendChild(icon); - row.appendChild(name); - row.appendChild(removeBtn); - container.appendChild(row); - } +function handleCardClick(jobId, event) { + const { selection: next, anchor } = display.resolveSelection( + orderedIds(), selection, jobId, + { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey }, + selectionAnchor + ); + selection = next; + selectionAnchor = anchor; + renderSelection(); +} + +function selectAll() { + selection = new Set(orderedIds()); + selectionAnchor = jobs.length > 0 ? jobs[0].id : null; + renderSelection(); +} + +function clearSelection() { + selection = new Set(); + selectionAnchor = null; + renderSelection(); +} + +function selectedJobs() { + return jobs.filter(j => selection.has(j.id)); } -function renderFormatGrid() { - const container = document.getElementById("format-grid"); - container.innerHTML = ""; +// -- Mutation ---------------------------------------------------------------- - // group jobs by type, but only show each type once in the format grid, since all jobs of the same type share the same target format options - const seen = new Set(); - const typeEntries = []; +function removeJobs(ids) { + const set = new Set(ids); + jobs = jobs.filter(j => !set.has(j.id)); + selection = display.pruneSelection(selection, orderedIds()); + render(); +} + +function setTarget(ids, targetExt) { + const set = new Set(ids); for (const job of jobs) { - if (!seen.has(job.type)) { - seen.add(job.type); - typeEntries.push({ type: job.type, entry: CONVERSION_MAP[job.ext] }); + if (!set.has(job.id)) continue; + // Guard against a stale bulk option: never assign a target the source + // cannot actually produce. + const allowed = FORMATS.conversionMap[job.ext]?.targets ?? []; + const crossKind = display.commonTargets([job.ext], TARGETS).map(t => t.ext); + if (!allowed.some(t => t.ext === targetExt) && !crossKind.includes(targetExt)) continue; + job.targetExt = targetExt; + if (job.status === "error" || job.status === "cancelled") { + job.status = "pending"; + job.error = null; + job.progress = 0; } + updateCard(job); } + renderActionBar(); +} + +// -- Rendering --------------------------------------------------------------- + +function render() { + const hasJobs = jobs.length > 0; + + $("empty-state").classList.toggle("d-none", hasJobs); + $("grid-scroll").classList.toggle("d-none", !hasJobs); + $("action-bar").classList.toggle("d-none", !hasJobs); + + const grid = $("job-grid"); + grid.innerHTML = ""; + for (const job of jobs) grid.appendChild(buildCard(job)); - for (const { type, entry } of typeEntries) { - const label = document.createElement("p"); - label.className = "format-group-label"; - label.textContent = type.charAt(0).toUpperCase() + type.slice(1); - container.appendChild(label); - - const row = document.createElement("div"); - row.className = "format-btn-row"; - - for (const t of entry.targets) { - const btn = document.createElement("button"); - btn.className = "btn-format"; - if (selectedFormats[type] === t.ext) btn.classList.add("selected"); - btn.textContent = t.label; - btn.dataset.type = type; - btn.dataset.ext = t.ext; - btn.addEventListener("click", () => selectFormat(type, t.ext, btn)); - row.appendChild(btn); + renderSelection(); + renderActionBar(); +} + +function buildCard(job) { + const card = document.createElement("div"); + card.className = "job-card"; + card.dataset.id = job.id; + + // -- head + const head = document.createElement("div"); + head.className = "card-head"; + + const icon = document.createElement("span"); + icon.className = "material-icons-round kind-icon"; + icon.textContent = display.iconForKind(job.kind); + + const name = document.createElement("span"); + name.className = "card-name"; + name.textContent = p.basename(job.filePath); + name.title = job.filePath; + + const remove = document.createElement("button"); + remove.className = "card-remove"; + remove.title = "Remove"; + const removeIcon = document.createElement("span"); + removeIcon.className = "material-icons-round"; + removeIcon.textContent = "close"; + remove.appendChild(removeIcon); + remove.addEventListener("click", (e) => { + e.stopPropagation(); + removeJobs([job.id]); + }); + + head.append(icon, name, remove); + + // -- meta + const meta = document.createElement("div"); + meta.className = "card-meta"; + + // -- convert row + const convert = document.createElement("div"); + convert.className = "card-convert"; + + const from = document.createElement("span"); + from.className = "card-from"; + from.textContent = job.ext.toUpperCase(); + + const arrow = document.createElement("span"); + arrow.className = "material-icons-round card-arrow"; + arrow.textContent = "arrow_forward"; + + const select = document.createElement("select"); + select.className = "form-select form-select-sm card-target"; + fillTargetSelect(select, display.commonTargets([job.ext], TARGETS), job.targetExt); + select.addEventListener("click", (e) => e.stopPropagation()); + select.addEventListener("change", () => setTarget([job.id], select.value || null)); + + convert.append(from, arrow, select); + + // -- status + const status = document.createElement("div"); + status.className = "card-status"; + const dot = document.createElement("span"); + dot.className = "status-dot"; + const statusText = document.createElement("span"); + statusText.className = "status-text"; + status.append(dot, statusText); + + // -- progress + const progress = document.createElement("div"); + progress.className = "card-progress d-none"; + const bar = document.createElement("div"); + bar.className = "bar"; + bar.style.width = "0%"; + progress.appendChild(bar); + + // -- actions (revealed once a job finishes) + const actions = document.createElement("div"); + actions.className = "card-actions d-none"; + + card.append(head, meta, convert, status, progress, actions); + card.addEventListener("click", (e) => handleCardClick(job.id, e)); + + paintCard(card, job); + return card; +} + +function fillTargetSelect(select, targets, current) { + select.innerHTML = ""; + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Choose format…"; + select.appendChild(placeholder); + + for (const { group, items } of display.groupTargets(targets)) { + const optgroup = document.createElement("optgroup"); + optgroup.label = group; + for (const t of items) { + const option = document.createElement("option"); + option.value = t.ext; + option.textContent = t.label; + optgroup.appendChild(option); } - container.appendChild(row); + select.appendChild(optgroup); } -} -function selectFormat(type, ext, btn) { - selectedFormats[type] = ext; - document.querySelectorAll(`.btn-format[data-type="${type}"]`).forEach(b => b.classList.remove("selected")); - btn.classList.add("selected"); - updateConvertButton(); + select.value = current ?? ""; } -function updateConvertButton() { - const types = [...new Set(jobs.map(j => j.type))]; - const allSelected = types.length > 0 && types.every(t => selectedFormats[t]); - document.getElementById("btn-start-convert").disabled = !allSelected; +/** Update one card in place, without rebuilding the grid. */ +function updateCard(job) { + const card = $("job-grid").querySelector(`[data-id="${job.id}"]`); + if (card) paintCard(card, job); + renderActionBar(); } -function setProgress(percent) { - const fill = document.getElementById("progress-bar-fill"); - const label = document.getElementById("progress-label"); +function paintCard(card, job) { + const meta = card.querySelector(".card-meta"); + const described = display.describeMeta(job.meta, job.ext); + meta.textContent = described ?? "Reading…"; + meta.classList.toggle("pending", described === null); + if (described) meta.title = described; + + const { text, tone } = display.describeStatus(job); + const status = card.querySelector(".card-status"); + status.className = `card-status tone-${tone}`; + const statusText = status.querySelector(".status-text"); + statusText.textContent = text; + statusText.title = text; + + const running = job.status === "running"; + const progress = card.querySelector(".card-progress"); + progress.classList.toggle("d-none", !running); + progress.classList.toggle("indeterminate", running && job.progress === null); + if (running && typeof job.progress === "number") { + progress.querySelector(".bar").style.width = `${job.progress}%`; + } + + // The target select locks while the job is in flight. + const select = card.querySelector(".card-target"); + select.disabled = running; + if (select.value !== (job.targetExt ?? "")) select.value = job.targetExt ?? ""; - if (percent == null) { - // No known duration, so show motion rather than the frozen 0% v1 sat at. - fill.style.width = "100%"; - fill.classList.add("progress-bar-striped", "progress-bar-animated"); - label.textContent = ""; - return; + renderCardActions(card, job); +} + +function renderCardActions(card, job) { + const actions = card.querySelector(".card-actions"); + actions.innerHTML = ""; + + const buttons = []; + + if (job.status === "running") { + buttons.push(makeAction("Cancel", "btn-outline-danger", () => api.cancelJob(job.id))); + } else if (job.status === "done" && job.outputPath) { + buttons.push(makeAction( + job.isDirectory ? "Open folder" : "Show in folder", + "btn-outline-secondary", + () => (job.isDirectory ? api.openPath(job.outputPath) : api.showInFolder(job.outputPath)) + )); + } else if (job.status === "error") { + buttons.push(makeAction("Retry", "btn-outline-secondary", () => { + job.status = "pending"; + job.error = null; + job.progress = 0; + updateCard(job); + })); } - fill.classList.remove("progress-bar-striped", "progress-bar-animated"); - fill.style.width = `${percent}%`; - label.textContent = `${Math.round(percent)}%`; + actions.classList.toggle("d-none", buttons.length === 0); + for (const b of buttons) actions.appendChild(b); } -async function startQueue() { - cancelRequested = false; +function makeAction(label, variant, onClick) { + const btn = document.createElement("button"); + btn.className = `btn btn-sm ${variant}`; + btn.textContent = label; + btn.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + return btn; +} - for (const job of jobs) { - job.targetExt = selectedFormats[job.type]; - job.status = "pending"; +function renderSelection() { + for (const card of $("job-grid").children) { + card.classList.toggle("selected", selection.has(card.dataset.id)); } - showState("converting"); + const count = selection.size; + const bar = $("bulk-bar"); + bar.classList.toggle("d-none", count === 0); + if (count === 0) return; + + $("bulk-count").textContent = `${count} selected`; + + // Offer only formats every selected source can actually produce, so a bulk + // change can never create an invalid job. + const targets = display.commonTargets(selectedJobs().map(j => j.ext), TARGETS); + const select = $("bulk-target"); + const shared = [...new Set(selectedJobs().map(j => j.targetExt))]; + fillTargetSelect(select, targets, shared.length === 1 ? shared[0] : null); + + select.disabled = targets.length === 0 || converting; + $("bulk-target-note").textContent = targets.length === 0 + ? "No format works for every selected file" + : ""; +} +function renderActionBar() { const total = jobs.length; + const ready = jobs.filter(j => j.targetExt).length; + const withoutTarget = total - ready; + + $("queue-summary").textContent = converting + ? display.summarise(jobs) + : `${total} ${total === 1 ? "file" : "files"} queued`; + + $("queue-detail").textContent = converting + ? `${jobs.filter(j => j.status === "running").length} running` + : (withoutTarget > 0 ? `${withoutTarget} still need a format` : "Ready to convert"); + + const percent = display.overallProgress(jobs); + $("overall-bar").style.width = `${percent}%`; + + // House pattern: the primary action and its abort share one slot rather + // than sitting side by side, so only one is ever visible. + $("btn-convert").classList.toggle("d-none", converting); + $("btn-convert").disabled = ready === 0; + $("btn-cancel-all").classList.toggle("d-none", !converting); + $("btn-clear-all").disabled = converting; +} - for (let i = 0; i < jobs.length; i++) { - const job = jobs[i]; +// -- Toasts ------------------------------------------------------------------ - if (cancelRequested) { - job.status = "cancelled"; - continue; - } +const TOAST_ICONS = { info: "info", success: "check_circle", warning: "warning", danger: "error" }; - document.getElementById("progress-current-file").textContent = p.basename(job.filePath); - document.getElementById("progress-status").textContent = `File ${i + 1} of ${total}`; - setProgress(0); +function toast(message, type = "info", timeoutMs = 4500) { + const host = $("toast-host"); - job.status = "converting"; + const note = document.createElement("div"); + note.className = `toast-note toast-${type}`; - const result = await api.runJob({ - id: job.id, - inputPath: job.filePath, - targetExt: job.targetExt, - }); - activeJobId = null; - - if (result.status === "cancelled" || cancelRequested) { - job.status = "cancelled"; - } else if (result.status === "done") { - job.status = "done"; - job.outputPath = result.outputPath; - job.isDirectory = result.isDirectory === true; - job.fileCount = result.fileCount ?? 1; - } else if (result.status === "skipped") { - job.status = "cancelled"; - } else { - job.status = "error"; - job.error = result.error ?? null; - } + const icon = document.createElement("span"); + icon.className = "material-icons-round"; + icon.textContent = TOAST_ICONS[type] ?? "info"; + + const body = document.createElement("span"); + body.className = "toast-body"; + body.textContent = message; + + const close = document.createElement("button"); + close.className = "toast-close material-icons-round"; + close.textContent = "close"; + close.title = "Dismiss"; + close.addEventListener("click", () => dismiss()); + + note.append(icon, body, close); + host.appendChild(note); + + let timer = null; + function dismiss() { + if (!note.isConnected) return; + clearTimeout(timer); + note.classList.add("leaving"); + setTimeout(() => note.remove(), 200); } - renderDoneList(); - showState("done"); + if (timeoutMs > 0) timer = setTimeout(dismiss, timeoutMs); + return dismiss; } -function renderDoneList() { - const doneCount = jobs.filter(j => j.status === "done").length; - const errorCount = jobs.filter(j => j.status === "error").length; - const cancelledCount = jobs.filter(j => j.status === "cancelled").length; +// -- Conversion -------------------------------------------------------------- - const parts = []; - if (doneCount > 0) parts.push(`${doneCount} ${doneCount !== 1 ? "files" : "file"} converted`); - if (errorCount > 0) parts.push(`${errorCount} ${parts.length === 0 ? (errorCount !== 1 ? "files " : "file ") : ""}failed`); - if (cancelledCount > 0) parts.push(`${cancelledCount} ${parts.length === 0 ? (cancelledCount !== 1 ? "files " : "file ") : ""}cancelled`); - let summary = parts.join(", "); - if (!summary) summary = "No files converted"; - document.getElementById("done-summary").textContent = summary; +async function startConversion() { + const runnable = jobs.filter(j => j.targetExt); + if (runnable.length === 0) return; - const listEl = document.getElementById("done-file-list"); - listEl.innerHTML = ""; + converting = true; + for (const job of runnable) { + job.status = "pending"; + job.progress = 0; + job.error = null; + updateCard(job); + } + renderActionBar(); - const STATUS_ICON = { - done: { text: "check_circle", cls: "text-success" }, - error: { text: "error", cls: "text-danger" }, - cancelled: { text: "cancel", cls: "text-secondary" }, - }; + // Every job is submitted at once; the runner's pool decides how many + // actually run in parallel. v1 awaited them one at a time. + const results = await Promise.all(runnable.map(async (job) => { + const result = await api.runJob({ + id: job.id, + inputPath: job.filePath, + targetExt: job.targetExt, + }); + applyResult(job, result); + return result; + })); - for (const job of jobs) { - const row = document.createElement("div"); - row.className = "file-row"; - - // v1 called fs.statSync here to find out whether the output was a - // folder of frames. The job result carries that now, so the renderer - // needs no filesystem access at all. - const isDir = job.status === "done" && job.isDirectory === true; - - const { text: iconText, cls } = STATUS_ICON[job.status] ?? STATUS_ICON.cancelled; - const icon = document.createElement("span"); - icon.className = `material-icons-round ${cls}`; - icon.textContent = isDir ? "folder" : iconText; - - const name = document.createElement("span"); - name.className = "file-row-name small"; - if (isDir) { - const frameCount = job.fileCount ?? 0; - name.textContent = `${p.basename(job.outputPath)}/ (${frameCount} ${frameCount !== 1 ? "frames" : "frame"})`; - } else { - name.textContent = p.basename(job.status === "done" ? job.outputPath : job.filePath); - } - name.title = job.status === "error" && job.error - ? job.error - : (job.status === "done" ? job.outputPath : job.filePath); - - row.appendChild(icon); - row.appendChild(name); - - if (job.status === "done") { - const openBtn = document.createElement("button"); - openBtn.className = "file-open-btn"; - openBtn.title = isDir ? "Open folder" : "Show in folder"; - const openIcon = document.createElement("span"); - openIcon.className = "material-icons-round"; - openIcon.textContent = isDir ? "folder_open" : "open_in_new"; - openBtn.appendChild(openIcon); - openBtn.addEventListener("click", () => { - if (isDir) api.openPath(job.outputPath); - else api.showInFolder(job.outputPath); - }); - row.appendChild(openBtn); - } + converting = false; + render(); + announce(results); +} + +function applyResult(job, result) { + job.status = result.status; + job.error = result.error ?? null; + job.outputPath = result.outputPath ?? null; + job.isDirectory = result.isDirectory === true; + job.fileCount = result.fileCount ?? 0; + if (result.status === "done") job.progress = 100; + updateCard(job); +} - listEl.appendChild(row); +function announce(results) { + const failed = results.filter(r => r.status === "error"); + const done = results.filter(r => r.status === "done").length; + const cancelled = results.filter(r => r.status === "cancelled" || r.status === "skipped").length; + + if (failed.length > 0) { + // One toast, not one blocking dialog per failure. The per-card status + // carries each individual reason. + const detail = failed.length === 1 ? ` — ${failed[0].error ?? "unknown error"}` : ""; + toast( + `${failed.length} ${failed.length === 1 ? "file" : "files"} failed to convert${detail}`, + "danger", + 0 + ); + } + if (done > 0) { + toast(`${done} ${done === 1 ? "file" : "files"} converted.`, "success"); + } else if (failed.length === 0 && cancelled > 0) { + toast("Conversion cancelled.", "info"); } } +// -- Wiring ------------------------------------------------------------------ + document.addEventListener("DOMContentLoaded", async () => { - const formats = await api.getFormats(); - CONVERSION_MAP = formats.conversionMap; - EXT_ALIASES = formats.aliases; + FORMATS = await api.getFormats(); + TARGETS = FORMATS.targetsByExt; - const dropZone = document.getElementById("drop-zone"); + // Drag and drop. The document-level guard stops a stray drop navigating the + // window to the file and replacing the app with it. + const shell = document.querySelector(".app-shell"); + let dragDepth = 0; - // A drop anywhere outside the zone would otherwise navigate the window to - // the dropped file, replacing the app with it. v1 had no such guard. - for (const type of ["dragover", "drop"]) { - document.addEventListener(type, (e) => e.preventDefault()); - } + document.addEventListener("dragover", (e) => e.preventDefault()); + document.addEventListener("drop", (e) => e.preventDefault()); - dropZone.addEventListener("dragover", (e) => { + document.addEventListener("dragenter", (e) => { e.preventDefault(); - dropZone.classList.add("drag-over"); + dragDepth++; + shell.classList.add("drag-over"); + if (jobs.length === 0) $("drop-zone").classList.add("drag-over"); }); - dropZone.addEventListener("dragleave", (e) => { - // dragleave fires when crossing onto a child element too, which made - // the v1 highlight flicker. Ignore those. - if (e.relatedTarget && dropZone.contains(e.relatedTarget)) return; - dropZone.classList.remove("drag-over"); + document.addEventListener("dragleave", () => { + // dragleave also fires crossing onto child elements, so count depth + // rather than clearing on the first one — that is what made the v1 + // highlight flicker. + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + shell.classList.remove("drag-over"); + $("drop-zone").classList.remove("drag-over"); + } }); - dropZone.addEventListener("drop", (e) => { - e.preventDefault(); - dropZone.classList.remove("drag-over"); + document.addEventListener("drop", (e) => { + dragDepth = 0; + shell.classList.remove("drag-over"); + $("drop-zone").classList.remove("drag-over"); ingestPaths(api.getPathsForFiles(e.dataTransfer.files)); }); - dropZone.addEventListener("click", async (e) => { - if (e.target.closest("button")) return; + async function browseFiles() { const filePaths = await api.browseFiles(); if (filePaths) ingestPaths(filePaths); + } + async function browseFolder() { + const folders = await api.browseFolder(); + if (folders) ingestPaths(folders); + } + + $("browse-btn").addEventListener("click", browseFiles); + $("btn-add-files").addEventListener("click", browseFiles); + $("btn-add-folder").addEventListener("click", browseFolder); + $("drop-zone").addEventListener("click", (e) => { + if (e.target.closest("button")) return; + browseFiles(); }); - async function browseFiles(e) { - e.stopPropagation(); - const filePaths = await api.browseFiles(); - if (filePaths) ingestPaths(filePaths); - } - document.getElementById("browse-btn").addEventListener("click", browseFiles); - document.getElementById("browse-more-btn").addEventListener("click", browseFiles); + $("btn-convert").addEventListener("click", startConversion); + $("btn-cancel-all").addEventListener("click", () => api.cancelAll()); + $("btn-clear-all").addEventListener("click", () => { + jobs = []; + clearSelection(); + render(); + }); + + $("bulk-target").addEventListener("change", (e) => { + if (e.target.value) setTarget([...selection], e.target.value); + }); + $("bulk-remove").addEventListener("click", () => removeJobs([...selection])); + $("bulk-deselect").addEventListener("click", clearSelection); - document.getElementById("btn-start-convert").addEventListener("click", startQueue); + // Clicking the grid background clears the selection. + $("grid-scroll").addEventListener("click", (e) => { + if (e.target === $("grid-scroll") || e.target === $("job-grid")) clearSelection(); + }); - document.getElementById("btn-cancel").addEventListener("click", async () => { - cancelRequested = true; - await api.cancelAll(); + document.addEventListener("keydown", (e) => { + const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName ?? ""); + if (typing) return; + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { + e.preventDefault(); + selectAll(); + } else if (e.key === "Delete" && selection.size > 0 && !converting) { + e.preventDefault(); + removeJobs([...selection]); + } else if (e.key === "Escape") { + clearSelection(); + } }); - function clearAll() { - jobs = []; - selectedFormats = {}; - showState("empty"); - } - document.getElementById("btn-clear-all").addEventListener("click", (e) => { e.stopPropagation(); clearAll(); }); - document.getElementById("btn-convert-another").addEventListener("click", clearAll); + render(); }); -// Progress now carries a jobId, so it can be matched to the file it belongs to -// rather than being applied blindly as v1 did. +// Progress carries a jobId, so it lands on the right card. v1 pushed a bare +// integer with no way to tell which file it belonged to. api.onJobProgress(({ jobId, percent }) => { const job = jobs.find(j => j.id === jobId); - if (job) job.progress = percent ?? 0; - if (activeJobId === null || activeJobId === jobId) setProgress(percent); + if (!job) return; + job.progress = percent; + updateCard(job); }); api.onJobStatus(({ jobId, status }) => { - if (status === "running") activeJobId = jobId; + const job = jobs.find(j => j.id === jobId); + if (!job || status === undefined) return; + if (status === "running") { + job.status = "running"; + updateCard(job); + } }); api.onFilesOpened((filePaths) => { diff --git a/src/styles.css b/src/styles.css index 39aa11a..720b28b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,8 +7,13 @@ html { body { margin: 0; - overflow-x: hidden; + height: 100vh; + overflow: hidden; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + /* Chrome is unselectable so dragging the UI does not select text, but + filenames, paths and error messages stay selectable — see .card-name, + .card-meta, .status-text and .toast-note. v1 set this globally, which + meant a user could not copy an error to report it. */ user-select: none; } @@ -22,6 +27,7 @@ body.popup-window { background-color: var(--bs-body-bg); color: var(--bs-body-color); padding: 20px; + height: auto; overflow: auto; } @@ -74,7 +80,7 @@ body.popup-window span:hover { flex-direction: column; align-items: center; justify-content: center; - width: min(580px, 100%); + width: min(520px, 100%); min-height: 200px; border: 2px dashed var(--bs-border-color); border-radius: var(--radius-zone); @@ -85,7 +91,8 @@ body.popup-window span:hover { text-align: center; } -.drop-zone:hover:not(:has(button:hover)) { +.drop-zone:hover:not(:has(button:hover)), +.drop-zone.drag-over { border-color: rgba(var(--accent-rgb), var(--wash-hover-border)); background-color: rgba(var(--accent-rgb), var(--wash-hover-fill)); } @@ -107,91 +114,143 @@ body.popup-window span:hover { margin: 0.75rem 0 0; } -/* ── Format grid ─────────────────────────────────────────────────────────── */ +/* ── App shell ───────────────────────────────────────────────────────────── */ -.format-group-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--bs-secondary-color); - margin: 1.25rem 0 0.5rem; - text-align: center; +.app-shell { + display: flex; + flex-direction: column; + height: 100vh; + padding: 1rem 1.25rem; + gap: 0.75rem; } -.format-btn-row { +.app-header { display: flex; - flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-shrink: 0; +} + +.app-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.empty-state { + flex: 1 1 auto; + display: flex; + align-items: center; justify-content: center; - gap: 0.5rem; + min-height: 0; } -.btn-format { - background-color: var(--bs-secondary-bg); - color: var(--bs-body-color); - border: 1px solid var(--bs-border-color); - border-radius: 6px; - padding: 0.4rem 1.1rem; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - transition: background-color 0.15s, border-color 0.15s, color 0.15s; +/* Once there are cards the whole grid area is the drop target, so there is no + separate zone to aim at. */ +.app-shell.drag-over .grid-scroll { + border-color: rgba(var(--accent-rgb), var(--wash-active-border)); + background-color: rgba(var(--accent-rgb), var(--wash-active-fill)); } -.btn-format:hover, -.btn-format.selected { - background-color: var(--accent); - border-color: var(--accent); - color: #fff; +/* ── Bulk edit bar ───────────────────────────────────────────────────────── */ + +.bulk-bar { + display: flex; + align-items: center; + gap: 0.6rem; + flex-shrink: 0; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-card); + background-color: rgba(var(--accent-rgb), var(--wash-semantic-fill)); + border: 1px solid rgba(var(--accent-rgb), var(--wash-semantic-border)); +} + +.bulk-sep { + width: 1px; + align-self: stretch; + background-color: var(--bs-border-color); + margin: 0 0.25rem; +} + +.bulk-select { + width: auto; + min-width: 150px; } -/* ── File list (queue state) ─────────────────────────────────────────────── */ +/* ── Grid ────────────────────────────────────────────────────────────────── */ -#file-list { - max-height: 280px; +.grid-scroll { + flex: 1 1 auto; + min-height: 0; overflow-y: auto; border: 1px solid var(--bs-border-color); border-radius: var(--radius-card); - padding: 0.25rem 0.6rem; + padding: 0.85rem; + transition: border-color var(--dur-state) var(--ease-state), + background-color var(--dur-state) var(--ease-state); +} + +.grid-scroll::-webkit-scrollbar { width: var(--scrollbar-width); } +.grid-scroll::-webkit-scrollbar-track { background: transparent; } +.grid-scroll::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } +.grid-scroll::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } + +.job-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)); + gap: 0.75rem; + align-content: start; } -#file-list::-webkit-scrollbar { width: var(--scrollbar-width); } -#file-list::-webkit-scrollbar-track { background: transparent; } -#file-list::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } -#file-list::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } +/* ── Job card ────────────────────────────────────────────────────────────── */ -.file-row { +.job-card { display: flex; - align-items: center; + flex-direction: column; gap: 0.5rem; - padding: 0.35rem 0; - border-bottom: 1px solid var(--bs-border-color); + padding: 0.75rem; + border-radius: var(--radius-card); + border: 1px solid var(--bs-border-color); + background-color: var(--bs-body-bg); + cursor: pointer; + transition: border-color var(--dur-micro) var(--ease-micro), + background-color var(--dur-micro) var(--ease-micro); } -.file-row:last-child { - border-bottom: none; +.job-card:hover { + border-color: rgba(var(--accent-rgb), var(--wash-hover-border)); } -.file-group-divider { - height: 4px; - background-color: var(--bs-border-color); - margin: 2px 0; - border-radius: 1px; +.job-card.selected { + border-color: rgba(var(--accent-rgb), var(--wash-active-border)); + background-color: rgba(var(--accent-rgb), var(--wash-active-fill)); +} + +.card-head { + display: flex; + align-items: center; + gap: 0.5rem; } -.file-row .material-icons-round { +.card-head .kind-icon { font-size: 18px; flex-shrink: 0; + color: var(--bs-secondary-color); } -.file-row-name { +.card-name { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 0.875rem; + font-weight: 500; + user-select: text; } -.file-remove-btn { +.card-remove { width: 20px; height: 20px; border-radius: 50%; @@ -209,48 +268,159 @@ body.popup-window span:hover { border-color var(--dur-micro) var(--ease-micro); } -.file-remove-btn:hover { +.card-remove:hover { background-color: var(--bs-danger); border-color: var(--bs-danger); color: #fff; } -.file-remove-btn .material-icons-round { +.card-remove .material-icons-round { font-size: 14px; line-height: 1; display: block; } -/* ── Done file list ──────────────────────────────────────────────────────── */ +.card-meta { + font-size: 0.78rem; + color: var(--bs-secondary-color); + min-height: 1.2em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; +} + +.card-meta.pending { + opacity: var(--opacity-disabled); +} -.file-open-btn { - border: none; - background: none; +.card-convert { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.card-from { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; color: var(--bs-secondary-color); - display: inline-flex; + flex-shrink: 0; +} + +.card-arrow { + font-size: 15px; + color: var(--bs-secondary-color); + flex-shrink: 0; +} + +.card-target { + flex: 1 1 auto; + min-width: 0; + font-size: 0.8rem; + padding: 0.15rem 1.4rem 0.15rem 0.5rem; +} + +/* ── Card status strip ───────────────────────────────────────────────────── */ + +.card-status { + display: flex; align-items: center; - justify-content: center; - padding: 0; - cursor: pointer; + gap: 0.4rem; + font-size: 0.75rem; + min-height: 1.4em; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; flex-shrink: 0; - transition: color var(--dur-micro) var(--ease-micro); + background-color: var(--bs-secondary-color); } -.file-open-btn:hover { - color: var(--bs-primary); +.status-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; } -.file-open-btn .material-icons-round { - font-size: 16px; - line-height: 1; - display: block; +.tone-ready .status-dot { background-color: var(--accent); } +.tone-ready .status-text { color: var(--bs-secondary-color); } +.tone-warning .status-dot { background-color: var(--bs-warning); } +.tone-warning .status-text { color: var(--bs-warning); } +.tone-danger .status-dot { background-color: var(--bs-danger); } +.tone-danger .status-text { color: var(--bs-danger); } +.tone-success .status-dot { background-color: var(--bs-success); } +.tone-success .status-text { color: var(--bs-success); } +.tone-muted .status-dot { background-color: var(--bs-secondary-color); opacity: var(--opacity-muted); } +.tone-muted .status-text { color: var(--bs-secondary-color); opacity: var(--opacity-muted); } + +/* Work-in-progress pulses, matching the house status-card pattern. */ +@keyframes status-pulse { + 0% { opacity: 0.75; } + 50% { opacity: 0.4; } + 100% { opacity: 0.75; } +} + +.tone-running .status-dot { + background-color: var(--accent); + animation: status-pulse var(--dur-ambient) infinite var(--ease-state); } -/* ── Done card ───────────────────────────────────────────────────────────── */ +.tone-running .status-text { color: var(--accent); } -.done-card { +.card-progress { + height: 4px; + border-radius: 2px; background-color: var(--bs-secondary-bg); - border: 1px solid var(--bs-border-color); + overflow: hidden; +} + +.card-progress .bar { + height: 100%; + background-color: var(--accent); + border-radius: 2px; + transition: width var(--dur-state) linear; +} + +/* Unknown duration: motion instead of a frozen bar. */ +.card-progress.indeterminate .bar { + width: 40% !important; + animation: slide 1.1s ease-in-out infinite; + transition: none; +} + +@keyframes slide { + 0% { margin-left: -40%; } + 100% { margin-left: 100%; } +} + +.card-actions { + display: flex; + gap: 0.35rem; +} + +.card-actions .btn { + --bs-btn-padding-y: 0.1rem; + --bs-btn-padding-x: 0.45rem; + --bs-btn-font-size: 0.72rem; +} + +/* ── Action bar ──────────────────────────────────────────────────────────── */ + +.action-bar { + display: flex; + align-items: center; + gap: 1.25rem; + flex-shrink: 0; + padding-top: 0.25rem; +} + +.action-status { + flex: 1 1 auto; + min-width: 0; } /* ── Spinner ─────────────────────────────────────────────────────────────── */ @@ -264,6 +434,62 @@ body.popup-window span:hover { animation: spin 1.4s linear infinite; } +/* ── Toast ───────────────────────────────────────────────────────────────── */ + +/* The house spec puts this at top:16px, which suits a web page whose content + starts below the fold. Here that sits directly on top of the app header and + hides the title and toolbar, so it clears the header instead. */ +.toast-host { + position: fixed; + top: 60px; + left: 50%; + transform: translateX(-50%); + z-index: 1080; + width: min(720px, calc(100% - 2rem)); + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast-note { + border-left: 4px solid; + border-radius: 6px; + padding: 0.7rem 1rem; + font-size: 0.875rem; + box-shadow: var(--bs-box-shadow); + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 0.5rem; + animation: toast-in var(--dur-state) var(--ease-state); + user-select: text; +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +.toast-note.leaving { + opacity: 0; + transform: translateY(-8px); + transition: opacity var(--dur-state), transform var(--dur-state); +} + +.toast-note .material-icons-round { font-size: 18px; flex-shrink: 0; } +.toast-note .toast-body { flex: 1 1 auto; min-width: 0; } +.toast-note .toast-close { + background: none; border: none; padding: 0; cursor: pointer; + color: inherit; opacity: 0.7; flex-shrink: 0; +} +.toast-note .toast-close:hover { opacity: 1; } + +.toast-info { background-color: var(--bs-info); border-left-color: var(--bs-info); color: #000; } +.toast-success { background-color: var(--bs-success); border-left-color: var(--bs-success); color: #fff; } +.toast-warning { background-color: var(--bs-warning); border-left-color: var(--bs-warning); color: #000; } +.toast-danger { background-color: var(--bs-danger); border-left-color: var(--bs-danger); color: #fff; } + /* ── Credits logo ────────────────────────────────────────────────────────── */ #credits-logo { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 411e97f..95745d0 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -81,6 +81,13 @@ --bs-focus-ring-color: rgba(var(--accent-rgb), 0.25); } +/* Bootstrap's progress bar has the same problem as .btn-primary below: .progress + sets --bs-progress-bar-bg to a literal #0d6efd rather than to var(--bs-primary), + so an accent-bound primary leaves the bar Bootstrap blue. */ +.progress { + --bs-progress-bar-bg: var(--accent); +} + /* Bootstrap derives .btn-primary from its own component-level variables, not from --bs-primary, so the button needs the accent applied directly. */ .btn-primary { diff --git a/test/display.test.js b/test/display.test.js new file mode 100644 index 0000000..1b0c67a --- /dev/null +++ b/test/display.test.js @@ -0,0 +1,274 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const fs = require("fs"); +const path = require("path"); + +const d = require("../src/core/display"); +const { STATUS } = require("../src/core/job"); +const formats = require("../src/core/formats"); + +// The renderer receives this map over IPC; display.js takes it as an argument +// because it cannot require core/formats itself. +const TARGETS = Object.fromEntries( + formats.SUPPORTED_EXTENSIONS.map(ext => [ext, formats.targetsFor(ext)]) +); + +// --------------------------------------------------------------------------- +// formatting +// --------------------------------------------------------------------------- + +test("display: durations read as clocks, and unknown stays unknown", () => { + assert.equal(d.formatDuration(0), "0:00"); + assert.equal(d.formatDuration(65), "1:05"); + assert.equal(d.formatDuration(3723), "1:02:03"); + assert.equal(d.formatDuration(59.6), "1:00", "rounds rather than truncating"); + assert.equal(d.formatDuration(null), null); + assert.equal(d.formatDuration(-1), null); + assert.equal(d.formatDuration(NaN), null); +}); + +test("display: byte sizes stay short", () => { + assert.equal(d.formatBytes(0), "0 B"); + assert.equal(d.formatBytes(512), "512 B"); + assert.equal(d.formatBytes(1024), "1.0 KB"); + assert.equal(d.formatBytes(1536), "1.5 KB"); + assert.equal(d.formatBytes(1048576), "1.0 MB"); + assert.equal(d.formatBytes(15 * 1048576), "15 MB", "drops the decimal above 10"); + assert.equal(d.formatBytes(null), null); +}); + +test("display: eta is terse", () => { + assert.equal(d.formatEta(45), "45s"); + assert.equal(d.formatEta(125), "2m 05s"); + assert.equal(d.formatEta(3700), "1h 01m"); + assert.equal(d.formatEta(null), null); +}); + +// --------------------------------------------------------------------------- +// card content +// --------------------------------------------------------------------------- + +test("display: metadata line is null until the probe lands", () => { + // The card must render before this returns anything. + assert.equal(d.describeMeta(null, "mp4"), null); + assert.equal(d.describeMeta({ ok: false }, "mp4"), null); +}); + +test("display: metadata line covers video and audio differently", () => { + const video = d.describeMeta({ + ok: true, duration: 65, size: 1048576, hasVideo: true, + video: { width: 1920, height: 1080, codec: "h264" }, + }, "mp4"); + assert.equal(video, "1920×1080 · 1:05 · h264 · 1.0 MB"); + + const audio = d.describeMeta({ + ok: true, duration: 185, size: 5242880, hasVideo: false, + audio: { codec: "mp3" }, + }, "mp3"); + assert.equal(audio, "3:05 · mp3 · 5.0 MB"); +}); + +test("display: a probe with nothing useful falls back to the extension", () => { + assert.equal(d.describeMeta({ ok: true }, "png"), "PNG"); +}); + +test("display: status tells the user what to do when no format is chosen", () => { + const noTarget = d.describeStatus({ status: STATUS.PENDING, targetExt: null }); + assert.equal(noTarget.tone, "warning"); + assert.match(noTarget.text, /format/i); + + assert.equal(d.describeStatus({ status: STATUS.PENDING, targetExt: "mp4" }).tone, "ready"); + assert.equal(d.describeStatus({ status: STATUS.RUNNING }).tone, "running"); + assert.equal(d.describeStatus({ status: STATUS.CANCELLED }).tone, "muted"); +}); + +test("display: a finished job surfaces its real failure reason", () => { + const failed = d.describeStatus({ status: STATUS.ERROR, error: "Invalid data found" }); + assert.equal(failed.text, "Invalid data found"); + assert.equal(failed.tone, "danger"); +}); + +test("display: a frames job reports its frame count, not just 'Done'", () => { + const frames = d.describeStatus({ status: STATUS.DONE, isDirectory: true, fileCount: 30 }); + assert.equal(frames.text, "30 frames"); + + const single = d.describeStatus({ status: STATUS.DONE, isDirectory: false }); + assert.equal(single.text, "Done"); +}); + +test("display: summary counts every outcome", () => { + const jobs = [ + { status: STATUS.DONE }, { status: STATUS.DONE }, + { status: STATUS.ERROR }, + { status: STATUS.CANCELLED }, { status: STATUS.SKIPPED }, + ]; + assert.equal(d.summarise(jobs), "2 files converted, 1 failed, 2 cancelled"); + assert.equal(d.summarise([{ status: STATUS.DONE }]), "1 file converted"); + assert.equal(d.summarise([]), "Nothing converted"); +}); + +test("display: overall progress counts finished jobs as complete", () => { + assert.equal(d.overallProgress([]), 0); + assert.equal(d.overallProgress([{ status: STATUS.DONE }, { status: STATUS.PENDING, progress: 0 }]), 50); + assert.equal(d.overallProgress([{ status: STATUS.RUNNING, progress: 50 }]), 50); + // A failed job is finished, not stuck — it must not hold the bar back. + assert.equal(d.overallProgress([{ status: STATUS.ERROR }, { status: STATUS.DONE }]), 100); +}); + +// --------------------------------------------------------------------------- +// selection +// --------------------------------------------------------------------------- + +const IDS = ["a", "b", "c", "d", "e"]; + +test("selection: a plain click replaces the selection", () => { + const r = d.resolveSelection(IDS, new Set(["a"]), "c", {}, "a"); + assert.deepEqual([...r.selection], ["c"]); + assert.equal(r.anchor, "c"); +}); + +test("selection: ctrl+click toggles", () => { + const added = d.resolveSelection(IDS, new Set(["a"]), "c", { ctrl: true }, "a"); + assert.deepEqual([...added.selection].sort(), ["a", "c"]); + + const removed = d.resolveSelection(IDS, new Set(["a", "c"]), "c", { ctrl: true }, "a"); + assert.deepEqual([...removed.selection], ["a"]); +}); + +test("selection: shift+click selects a range in either direction", () => { + const forward = d.resolveSelection(IDS, new Set(["b"]), "d", { shift: true }, "b"); + assert.deepEqual([...forward.selection], ["b", "c", "d"]); + + const backward = d.resolveSelection(IDS, new Set(["d"]), "b", { shift: true }, "d"); + assert.deepEqual([...backward.selection], ["b", "c", "d"]); + + assert.equal(forward.anchor, "b", "the anchor survives a shift range"); +}); + +test("selection: ctrl+shift extends rather than replacing", () => { + const r = d.resolveSelection(IDS, new Set(["e"]), "c", { ctrl: true, shift: true }, "b"); + assert.deepEqual([...r.selection].sort(), ["b", "c", "e"]); +}); + +test("selection: clicking inside a multi-selection keeps it", () => { + // Otherwise acting on a group would collapse it to one card first. + const r = d.resolveSelection(IDS, new Set(["a", "b", "c"]), "b", {}, "a"); + assert.deepEqual([...r.selection].sort(), ["a", "b", "c"]); + assert.equal(r.anchor, "b"); +}); + +test("selection: clicking outside a multi-selection replaces it", () => { + const r = d.resolveSelection(IDS, new Set(["a", "b"]), "e", {}, "a"); + assert.deepEqual([...r.selection], ["e"]); +}); + +test("selection: a stale anchor degrades to a plain click", () => { + const r = d.resolveSelection(IDS, new Set(), "c", { shift: true }, "zzz"); + assert.deepEqual([...r.selection], ["c"]); +}); + +test("selection: removals cannot strand ids", () => { + const pruned = d.pruneSelection(new Set(["a", "gone", "c"]), IDS); + assert.deepEqual([...pruned].sort(), ["a", "c"]); +}); + +// --------------------------------------------------------------------------- +// bulk target resolution +// --------------------------------------------------------------------------- + +test("targets: a single source offers everything it can become", () => { + const t = d.commonTargets(["mp4"], TARGETS); + const exts = t.map(x => x.ext); + assert.ok(exts.includes("mkv"), "same-kind"); + assert.ok(exts.includes("mp3"), "cross-kind extraction"); + assert.ok(exts.includes("png"), "frame export"); +}); + +test("targets: a mixed selection offers only the intersection", () => { + // Video and audio share audio targets, but a video-only target such as mkv + // must not be offered, or bulk-setting it would produce invalid jobs. + const t = d.commonTargets(["mp4", "mp3"], TARGETS).map(x => x.ext); + assert.ok(t.includes("mp3")); + assert.ok(t.includes("flac")); + assert.ok(!t.includes("mkv"), "mp3 cannot become mkv"); + assert.ok(!t.includes("png"), "mp3 cannot become png"); +}); + +test("targets: an impossible mix offers nothing rather than something invalid", () => { + // Images cannot become audio, audio cannot become images. + assert.deepEqual(d.commonTargets(["mp3", "png"], TARGETS), []); +}); + +test("targets: empty and unknown input is handled", () => { + assert.deepEqual(d.commonTargets([], TARGETS), []); + assert.deepEqual(d.commonTargets([null, undefined], TARGETS), []); +}); + +test("targets: grouping puts same-kind formats first", () => { + const groups = d.groupTargets(d.commonTargets(["mp4"], TARGETS)); + assert.equal(groups[0].group, "Video", "video sources lead with video targets"); + assert.ok(groups.length > 1, "cross-kind groups still offered"); +}); + +test("display: kind icons cover every kind and fall back safely", () => { + assert.equal(d.iconForKind("audio"), "audio_file"); + assert.equal(d.iconForKind("video"), "video_file"); + assert.equal(d.iconForKind("image"), "image"); + assert.equal(d.iconForKind("nonsense"), "insert_drive_file"); +}); + +// --------------------------------------------------------------------------- +// dual-load guards +// +// display.js is required here in Node AND loaded as a plain diff --git a/src/main.js b/src/main.js index 920a83a..46abe3a 100644 --- a/src/main.js +++ b/src/main.js @@ -11,6 +11,7 @@ const probe = require("./core/probe"); const paths = require("./core/paths"); const scan = require("./core/scan"); const pipeline = require("./core/pipeline"); +const version = require("./core/version"); const LOG_LEVELS = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3 }; @@ -147,6 +148,18 @@ function createCreditsWindow() { creditsWindow.show(); creditsWindow.focus(); }); + + // Escape closes the window. Handled in the main process rather than with a + // keydown listener in the page: before-input-event fires no matter where + // focus sits inside the document, and does not depend on the page script + // having run. The page-level handler worked when run unpackaged but not in + // the packaged build, which is exactly the fragility this avoids. + creditsWindow.webContents.on("before-input-event", (event, input) => { + if (input.type === "keyDown" && input.key === "Escape") { + event.preventDefault(); + if (!creditsWindow.isDestroyed()) creditsWindow.close(); + } + }); creditsWindow.setMenu(null); creditsWindow.loadFile(path.join(__dirname, "credits.html")); } @@ -269,15 +282,41 @@ function getRunner() { } }); + // "Apply to all remaining" is scoped to one batch, not to the session. + runner.on("idle", () => { conflictChoiceForBatch = null; }); + return runner; } +// A choice the user asked to apply to the rest of the batch. Cleared whenever +// the runner goes idle, so it never leaks into the next run. +let conflictChoiceForBatch = null; + +/** Turn a chosen action into the result the runner expects. */ +function applyConflictChoice(choice, candidatePath, isDirectory) { + if (choice === "cancel") return { action: "cancel" }; + if (choice === "overwrite") return { action: "write", outputPath: candidatePath }; + return { + action: "write", + outputPath: isDirectory ? paths.getUniqueDirPath(candidatePath) : paths.getUniquePath(candidatePath), + }; +} + /** - * The output-exists prompt. Kept as a native dialog with v1's exact button - * set, so the behaviour users already know is unchanged. + * The output-exists prompt, with v1's exact button set so the behaviour users + * already know is unchanged. + * + * The checkbox matters: jobs run through a concurrency pool, so without a way + * to answer once for the whole batch, converting fifty files into a folder + * that already has them would mean fifty dialogs. */ async function resolveConflict(job, candidatePath) { const isDirectory = formats.producesDirectory(job.mode); + + if (conflictChoiceForBatch) { + return applyConflictChoice(conflictChoiceForBatch, candidatePath, isDirectory); + } + const result = await dialog.showMessageBox(mainWindow, { type: "question", title: "File Already Exists", @@ -288,14 +327,14 @@ async function resolveConflict(job, candidatePath) { buttons: ["Cancel", "Overwrite", "Save as New"], defaultId: 2, cancelId: 0, + checkboxLabel: "Apply to all remaining files", + checkboxChecked: false, }); - if (result.response === 0) return { action: "cancel" }; - if (result.response === 1) return { action: "write", outputPath: candidatePath }; - return { - action: "write", - outputPath: isDirectory ? paths.getUniqueDirPath(candidatePath) : paths.getUniquePath(candidatePath), - }; + const choice = ["cancel", "overwrite", "unique"][result.response] ?? "cancel"; + if (result.checkboxChecked) conflictChoiceForBatch = choice; + + return applyConflictChoice(choice, candidatePath, isDirectory); } /** @@ -323,7 +362,18 @@ function runJobToCompletion(job) { // ── IPC ────────────────────────────────────────────────────────────────────── ipcMain.handle(IPC.JOB_RUN, async (_event, spec) => { - const job = createJob(spec); + const settings = store.get("settings") ?? SETTINGS_DEFAULTS; + + const job = createJob({ + ...spec, + output: { + routing: settings.outputRouting, + dir: settings.outputDir, + nameTemplate: settings.nameTemplate, + onConflict: settings.onConflict, + ...spec.output, + }, + }); // Metadata is what gives progress a denominator, so probe before running. // A failed probe is not fatal; the job simply reports indeterminate progress. @@ -446,9 +496,31 @@ function setupAutoUpdater() { autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; + // Pre-releases are never an update target. The updater only ever looks at + // the latest stable, whatever the user is currently running. + // + // This has to be explicit: electron-updater turns allowPrerelease ON BY + // ITSELF when the running version carries a pre-release tag, so shipping + // 2.0.0-alpha.1 would silently opt every alpha tester into being updated to + // the next alpha. allowDowngrade stays off so an alpha user is not dragged + // back to an older stable either — they simply get nothing until a stable + // release supersedes what they are running. + autoUpdater.allowPrerelease = false; + autoUpdater.allowDowngrade = false; + autoUpdater.channel = "latest"; + autoUpdater.on("update-available", (info) => { const currentVersion = app.getVersion(); const newVersion = info.version; + + // Belt and braces. allowPrerelease above should mean this never fires + // for a pre-release, but a mis-tagged GitHub release would otherwise + // push an alpha at every user, so the offer is checked again here. + if (!version.isOfferableUpdate(newVersion, currentVersion)) { + log(LOG.WARN, `Ignoring update ${newVersion}: not an offerable stable release`); + return; + } + dialog.showMessageBox(mainWindow, { type: "info", title: "Update Available", @@ -493,8 +565,16 @@ function setupAutoUpdater() { function checkForUpdatesManually() { const { autoUpdater } = require("electron-updater"); + autoUpdater.allowPrerelease = false; + autoUpdater.allowDowngrade = false; + autoUpdater.channel = "latest"; + autoUpdater.checkForUpdates().then(result => { - if (!result || !result.updateInfo || result.updateInfo.version === app.getVersion()) { + const found = result?.updateInfo?.version ?? null; + // Equality was the wrong test: running 2.0.0-alpha.1 against a latest + // stable of 1.0.0 is neither equal nor an update, and the check used to + // fall through and do nothing at all. + if (!version.isOfferableUpdate(found, app.getVersion())) { dialog.showMessageBox(mainWindow, { type: "info", title: "No Updates", diff --git a/test/version.test.js b/test/version.test.js new file mode 100644 index 0000000..7d5ee83 --- /dev/null +++ b/test/version.test.js @@ -0,0 +1,153 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); + +const v = require("../src/core/version"); + +test("version: parses plain and pre-release versions", () => { + assert.deepEqual(v.parse("1.2.3"), { major: 1, minor: 2, patch: 3, prerelease: [] }); + assert.deepEqual(v.parse("2.0.0-alpha.1"), { major: 2, minor: 0, patch: 0, prerelease: ["alpha", "1"] }); + assert.deepEqual(v.parse("v1.0.0"), { major: 1, minor: 0, patch: 0, prerelease: [] }); + assert.equal(v.parse("not-a-version"), null); + assert.equal(v.parse(null), null); +}); + +test("version: identifies pre-releases", () => { + assert.equal(v.isPrerelease("2.0.0-alpha.1"), true); + assert.equal(v.isPrerelease("2.0.0-beta"), true); + assert.equal(v.isPrerelease("2.0.0-rc.2"), true); + assert.equal(v.isPrerelease("2.0.0"), false); + assert.equal(v.isPrerelease("1.0.0"), false); +}); + +test("version: orders by major, minor, patch", () => { + assert.equal(v.compare("1.0.0", "2.0.0"), -1); + assert.equal(v.compare("2.0.0", "1.0.0"), 1); + assert.equal(v.compare("1.2.0", "1.10.0"), -1, "compares numerically, not as strings"); + assert.equal(v.compare("1.0.10", "1.0.9"), 1); + assert.equal(v.compare("1.0.0", "1.0.0"), 0); +}); + +test("version: a pre-release precedes its own release", () => { + assert.equal(v.compare("2.0.0-alpha.1", "2.0.0"), -1); + assert.equal(v.compare("2.0.0", "2.0.0-alpha.1"), 1); + assert.equal(v.compare("2.0.0-alpha.1", "2.0.0-alpha.2"), -1); + assert.equal(v.compare("2.0.0-alpha.2", "2.0.0-beta.1"), -1); + assert.equal(v.compare("2.0.0-alpha.1", "2.0.0-alpha.1"), 0); + // A longer pre-release chain is greater when the prefix matches. + assert.equal(v.compare("2.0.0-alpha", "2.0.0-alpha.1"), -1); +}); + +test("version: numeric pre-release parts rank below alphanumeric ones", () => { + assert.equal(v.compare("1.0.0-1", "1.0.0-alpha"), -1); +}); + +// --------------------------------------------------------------------------- +// The rule the updater enforces +// --------------------------------------------------------------------------- + +test("update: a newer stable release is offered", () => { + assert.equal(v.isOfferableUpdate("1.1.0", "1.0.0"), true); + assert.equal(v.isOfferableUpdate("2.0.0", "1.0.0"), true); +}); + +test("update: a pre-release is NEVER offered, whatever the user is running", () => { + assert.equal(v.isOfferableUpdate("2.0.0-alpha.1", "1.0.0"), false, "stable user"); + assert.equal(v.isOfferableUpdate("2.0.0-alpha.2", "2.0.0-alpha.1"), false, "pre-release user"); + assert.equal(v.isOfferableUpdate("3.0.0-rc.1", "2.0.0"), false, "even a much newer one"); +}); + +test("update: someone on a pre-release is offered the stable that supersedes it", () => { + // 2.0.0 final supersedes 2.0.0-alpha.1, so it IS offered. + assert.equal(v.isOfferableUpdate("2.0.0", "2.0.0-alpha.1"), true); +}); + +test("update: an older stable is not offered to a pre-release user", () => { + // The case that silently did nothing: running 2.0.0-alpha.1 while the + // latest stable is 1.0.0. That is a downgrade and must be refused. + assert.equal(v.isOfferableUpdate("1.0.0", "2.0.0-alpha.1"), false); +}); + +test("update: the same version is not an update", () => { + assert.equal(v.isOfferableUpdate("1.0.0", "1.0.0"), false); + assert.equal(v.isOfferableUpdate("2.0.0-alpha.1", "2.0.0-alpha.1"), false); +}); + +test("update: garbage is never offered", () => { + assert.equal(v.isOfferableUpdate(null, "1.0.0"), false); + assert.equal(v.isOfferableUpdate("", "1.0.0"), false); + assert.equal(v.isOfferableUpdate("latest", "1.0.0"), false); +}); + +// --------------------------------------------------------------------------- +// The updater configuration itself +// --------------------------------------------------------------------------- + +test("updater: pre-releases are explicitly disallowed in main.js", () => { + const main = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8"); + + // electron-updater turns allowPrerelease ON BY ITSELF when the running + // version is a pre-release, which would make an alpha update to the next + // alpha. It has to be pinned off explicitly. + assert.match(main, /allowPrerelease\s*=\s*false/, + "main.js must set autoUpdater.allowPrerelease = false"); + assert.match(main, /allowDowngrade\s*=\s*false/, + "main.js must set autoUpdater.allowDowngrade = false"); + assert.match(main, /channel\s*=\s*"latest"/, + "main.js must pin the updater to the latest stable channel"); + assert.ok(!/allowPrerelease\s*=\s*true/.test(main), + "main.js must never enable pre-release updates"); +}); + +test("updater: the version currently shipping matches package.json", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); + assert.ok(v.parse(pkg.version), `package.json version "${pkg.version}" is not valid semver`); +}); + +// --------------------------------------------------------------------------- +// Conflict policy wiring +// +// The resolver in main.js was unreachable because the job was built without +// the user's conflict setting, so createJob's default silently renamed every +// collision to "name (1).ext". The prompt is only reachable if the setting is +// actually threaded through. +// --------------------------------------------------------------------------- + +test("conflict: the documented default is to ask, not to rename silently", () => { + const { SETTINGS_DEFAULTS } = require("../src/constants"); + const { createJob, CONFLICT } = require("../src/core/job"); + + assert.equal(SETTINGS_DEFAULTS.onConflict, "ask"); + assert.equal(createJob({ inputPath: "a.mp4", targetExt: "mkv" }).output.onConflict, CONFLICT.ASK); +}); + +test("conflict: main.js threads the user's setting into the job", () => { + const main = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8"); + assert.match(main, /onConflict:\s*settings\.onConflict/, + "main.js must pass settings.onConflict into createJob, or the prompt is unreachable"); + assert.match(main, /conflictResolver:\s*resolveConflict/, + "the runner must be given the resolver"); +}); + +test("conflict: the prompt offers an escape hatch for a whole batch", () => { + const main = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8"); + // Without this, a batch of 50 collisions means 50 modal dialogs. + assert.match(main, /checkboxLabel:\s*"Apply to all remaining files"/); + assert.match(main, /conflictChoiceForBatch\s*=\s*null/, + "the batch-wide choice must be reset so it cannot leak into the next run"); +}); + +test("credits: Escape is handled in the main process, not the page", () => { + const main = fs.readFileSync(path.join(__dirname, "..", "src", "main.js"), "utf8"); + assert.match(main, /before-input-event/, + "Escape must be handled via before-input-event, which does not depend on the page script"); + assert.match(main, /input\.key === "Escape"/); + + // The page-level handler was the fragile version that failed when packaged. + const credits = fs.readFileSync(path.join(__dirname, "..", "src", "credits.html"), "utf8"); + assert.ok(!/addEventListener\(\s*["']keydown["']/.test(credits), + "credits.html should no longer rely on its own keydown listener"); +}); From 06761f1cb560d8ef1f8f32a5edc5d98d8e95d6bc Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 28 Aug 2026 14:55:58 +0100 Subject: [PATCH 10/81] feat: New Job dialog for per-file encoding settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine has supported codec choice, constant quality, bitrate, encoder presets, resize with fit modes, frame rate, trim, stream copy, output routing and name templating since the architecture slice. None of it was reachable. This is the interface for it. Every control is built from the target format's own capability descriptors, so a container is never offered a codec it cannot carry: WebM offers VP9 and not H.264, MP4 the reverse, MP3 only its own. Sections appear and disappear with the target — an audio target has no video controls, an image target has no audio, a still image has no trim while a GIF does. Invalid combinations are unreachable rather than merely rejected on submit. A live command preview runs the spec through the real argument builder in the main process, so what the dialog shows is what will actually be executed rather than a reconstruction that could drift from it. The dialog edits a *partial* spec. Anything left untouched is omitted entirely and createJob fills it from the format's defaults, so the dialog does not freeze today's defaults into every job. Applying with nothing changed leaves the job exactly as it was, and the card shows no settings summary. Deliberately not the only way to set a format. The per-card dropdown and the bulk bar still work, so "drop a folder, pick a format, go" stays two clicks — the point raised when the grid landed. Presets save and restore the whole configuration including the target format, through the store bindings that were already wired. Cards gain a tune button that opens the dialog for that one file, and a green summary line describing what has been configured. Three bugs found by driving the dialog rather than by reading it: - CRF was silently discarded whenever the codec select sat on "Format default". The effective codec is the chosen one *or* the container's default, and treating the empty select as "no codec" disabled constant quality and dropped the value on apply. - Switching to a codec with no constant-quality mode forced the control to bitrate and never released it, so returning to H.264 left it stuck and threw the CRF away again. - Both stream selects rendered blank. Assigning a value no option carries leaves selectedIndex at -1, which reads back as "" and collapsed the whole encode section, since those fields only show when the mode is "encode". Verified end to end against ffmpeg: a spec shaped exactly as the dialog produces one resizes, re-times, trims and downmixes the real output. --- docs/MANUAL-TESTING.md | 83 ++++++++- src/constants.js | 1 + src/core/display.js | 136 ++++++++++++++ src/index.html | 200 ++++++++++++++++++++ src/main.js | 20 ++ src/preload.js | 2 + src/renderer.js | 401 ++++++++++++++++++++++++++++++++++++++++- src/styles.css | 78 ++++++++ test/display.test.js | 118 ++++++++++++ 9 files changed, 1036 insertions(+), 3 deletions(-) diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index d86c1ea..674c42d 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -11,7 +11,7 @@ sections — the history is useful. ``` npm install -npm test # expect 114 passing, 0 failing +npm test # expect 126 passing, 0 failing npm start ``` @@ -267,3 +267,84 @@ The app currently ships as `2.0.0-alpha.1` while the latest stable is `1.0.0`. - [ ] On a stable build, a newer stable release *is* still offered normally — this must not have broken ordinary updates. - [ ] "View Changelog" from an update dialog opens the correct release page. + +--- + +## 2.0.0-alpha.2 — New Job dialog + +Per-file encoding settings. The engine already supported all of this; this pass +exposes it. **The plain path must stay two clicks** — check that first. + +### The simple path is not disturbed + +- [ ] Drop a folder of images, pick one format in the bulk bar, press Convert. + Still two clicks. The dialog must not be required for this. +- [ ] The per-card format dropdown still works on its own. + +### Opening the dialog + +- [ ] **New Job** in the top-right is disabled with an empty grid, enabled once + files are queued, and disabled again while a conversion runs. +- [ ] With nothing selected it opens for every file and the subtitle reads + "Configuring N files". +- [ ] With cards selected it opens for just those. +- [ ] The tune icon on a card opens it for that one file only, and does not + change the selection. +- [ ] Escape and the × both close it without applying anything. + +### Controls follow the target format + +- [ ] Choose an **audio** target (MP3): the Video section disappears entirely. +- [ ] Choose an **image** target (PNG): the Audio section disappears and an + Image quality slider appears. Trim disappears for a still image. +- [ ] Choose **GIF**: Trim stays available, because a GIF can be animated. +- [ ] Choose **WebM**: the codec list offers VP9/VP8/AV1 and **not** H.264. +- [ ] Choose **MP4**: it offers H.264/H.265/AV1 and **not** VP9. +- [ ] Choose **MP3**: the audio codec list offers only MP3. +- [ ] Set Stream to **Copy without re-encoding**: codec, quality, resize and + frame rate all disappear, since none of them apply to a remux. +- [ ] Set Stream to **Remove video**: same, and the preview gains `-vn`. +- [ ] Pick a codec with no constant-quality mode (MPEG-4 in AVI): Quality + switches to bitrate and locks. Switch back to H.264 — it must return to + **Constant quality**, not stay stuck on bitrate. +- [ ] Select a video and an image together: no shared format exists, the target + list is empty and the dialog says so. + +### Command preview + +- [ ] The preview updates as you change controls, and starts with `ffmpeg`. +- [ ] It reflects what you set — a CRF of 20 appears as `-crf 20`, a resize as + `scale=...`, a trim as `-ss`. +- [ ] The preview text can be selected and copied. *(It is the one deliberate + exception to nothing-is-selectable.)* +- [ ] An invalid combination shows a red message and disables **Apply**. + +### Applying + +- [ ] Apply, then check the card: a green summary line appears under the + metadata describing what was set, e.g. "H.264 · CRF 20 · 1280×720". +- [ ] Open the dialog again on that card — your settings are still there. +- [ ] Apply with nothing changed: the card gains **no** summary line. Untouched + settings must not be baked in. +- [ ] Apply to a multi-file selection: every selected card gets the summary. +- [ ] Convert, and confirm the output honours the settings — check the + resolution, duration and audio channels of the result, not just that a + file appeared. + +### Presets + +- [ ] Configure something, **Save as…**, give it a name. It appears in the + Preset dropdown. +- [ ] Change the fields, then re-select the preset — your saved values come + back, including the target format. +- [ ] **Delete** removes it, and it is gone after reopening the dialog. +- [ ] Presets survive restarting the app. + +### Output routing + +- [ ] Destination **A folder I choose** reveals a Browse button; picking a + folder puts the path in the field. +- [ ] Convert and confirm the output lands in that folder, not next to the + source. +- [ ] A name template of `{name}-web` produces `something-web.mp4`. +- [ ] **If it already exists** set to Overwrite converts without prompting. diff --git a/src/constants.js b/src/constants.js index dd5761c..c69ca35 100644 --- a/src/constants.js +++ b/src/constants.js @@ -18,6 +18,7 @@ const IPC = { // no way to tell which file it belonged to. JOB_RUN: "job:run", // invoke(job) -> result JOB_CANCEL: "job:cancel", // invoke(jobId) -> boolean + JOB_PREVIEW: "job:preview", // invoke(spec) -> ffmpeg argv preview 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", diff --git a/src/core/display.js b/src/core/display.js index 07519cf..8310255 100644 --- a/src/core/display.js +++ b/src/core/display.js @@ -162,6 +162,135 @@ return KIND_ICONS[kind] || "insert_drive_file"; } + // -- New Job dialog ------------------------------------------------------ + // + // Which controls a target format actually supports. The dialog is driven + // entirely from these, so a container is never offered a codec it cannot + // carry — invalid combinations are unreachable rather than merely rejected. + + /** Which sections of the dialog apply to this target format. */ + function applicableSections(targetExt, descriptors) { + const target = descriptors ? descriptors[targetExt] : null; + if (!target) return { video: false, audio: false, image: false, trim: false, resize: false }; + + const isImage = target.kind === "image"; + const isAudio = target.kind === "audio"; + + return { + // An audio container has no video stream to configure. + video: !isAudio, + // An image has no audio stream. + audio: !isImage, + // Only image targets expose a quality knob of their own. + image: isImage && !!target.quality, + // Trimming a single still makes no sense. + trim: !(isImage && !target.animated), + resize: !isAudio, + }; + } + + /** Codec choices for a target, as {value,label} options. Empty if none. */ + function codecOptions(targetExt, descriptors, stream) { + const target = descriptors ? descriptors[targetExt] : null; + if (!target) return []; + const list = stream === "audio" ? target.audioCodecs : target.videoCodecs; + if (!Array.isArray(list)) return []; + return list.map(c => ({ value: c, label: CODEC_LABELS[c] || c })); + } + + const CODEC_LABELS = { + libx264: "H.264", libx265: "H.265 / HEVC", libsvtav1: "AV1", + "libvpx-vp9": "VP9", libvpx: "VP8", mpeg4: "MPEG-4", mjpeg: "MJPEG", + prores_ks: "ProRes", wmv2: "WMV", msmpeg4v3: "MS MPEG-4", flv: "Sorenson", + gif: "GIF", png: "PNG", bmp: "BMP", tiff: "TIFF", libwebp: "WebP", + aac: "AAC", libmp3lame: "MP3", libopus: "Opus", libvorbis: "Vorbis", + flac: "FLAC", alac: "ALAC", ac3: "AC-3", wmav2: "WMA", + pcm_s16le: "PCM 16-bit", pcm_s24le: "PCM 24-bit", pcm_f32le: "PCM 32-bit float", + }; + + /** Whether a target can stream-copy, i.e. remux without re-encoding. */ + function canStreamCopy(targetExt, descriptors) { + const target = descriptors ? descriptors[targetExt] : null; + return !!(target && target.supportsStreamCopy); + } + + /** The image quality knob for a target, or null. */ + function qualityDescriptor(targetExt, descriptors) { + const target = descriptors ? descriptors[targetExt] : null; + return target && target.quality ? target.quality : null; + } + + /** + * A one-line summary of whatever has been changed from the defaults, shown + * on the card so a configured job is visibly different from a bare one. + * Returns null when nothing has been customised. + */ + function summariseSettings(settings) { + if (!settings) return null; + const parts = []; + + if (settings.video) { + const v = settings.video; + if (v.mode === "copy") parts.push("copy video"); + else if (v.mode === "drop") parts.push("no video"); + else { + if (v.codec) parts.push(CODEC_LABELS[v.codec] || v.codec); + if (v.crf != null) parts.push(`CRF ${v.crf}`); + else if (v.bitrate) parts.push(`${v.bitrate}k`); + if (v.preset) parts.push(v.preset); + } + if (v.width || v.height) parts.push(`${v.width || "auto"}×${v.height || "auto"}`); + if (v.fps) parts.push(`${v.fps} fps`); + } + + if (settings.audio) { + const a = settings.audio; + if (a.mode === "copy") parts.push("copy audio"); + else if (a.mode === "drop") parts.push("no audio"); + else { + if (a.codec) parts.push(CODEC_LABELS[a.codec] || a.codec); + if (a.bitrate) parts.push(`${a.bitrate}k`); + if (a.channels) parts.push(a.channels === 1 ? "mono" : `${a.channels}ch`); + } + } + + if (settings.image && settings.image.quality != null) { + parts.push(`q${settings.image.quality}`); + } + + const trim = settings.trim; + if (trim && (trim.start != null || trim.end != null)) { + parts.push(`trim ${formatDuration(trim.start || 0) || "0:00"}–${formatDuration(trim.end) || "end"}`); + } + + if (settings.output) { + const o = settings.output; + if (o.routing && o.routing !== "alongside") parts.push(o.routing === "mirror" ? "mirrored" : "custom folder"); + if (o.nameTemplate && o.nameTemplate !== "{name}") parts.push("renamed"); + } + + return parts.length > 0 ? parts.join(" · ") : null; + } + + /** + * Strip empty sections and null fields so a spec carries only what was + * actually set. Anything left out falls back to the format's own defaults + * in createJob, which keeps the dialog from freezing today's defaults in. + */ + function compactSettings(settings) { + const out = {}; + for (const [section, values] of Object.entries(settings || {})) { + if (!values || typeof values !== "object") continue; + const kept = {}; + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined || value === "") continue; + kept[key] = value; + } + if (Object.keys(kept).length > 0) out[section] = kept; + } + return out; + } + // -- Selection ----------------------------------------------------------- /** @@ -269,6 +398,13 @@ STATUS, plural, countOf, + applicableSections, + codecOptions, + canStreamCopy, + qualityDescriptor, + summariseSettings, + compactSettings, + CODEC_LABELS, formatDuration, formatBytes, formatEta, diff --git a/src/index.html b/src/index.html index 6362fe4..2b2c26b 100644 --- a/src/index.html +++ b/src/index.html @@ -30,6 +30,9 @@

Diamond File Converter

+
@@ -82,6 +85,203 @@

Diamond File Converter

+ + +
diff --git a/src/main.js b/src/main.js index 46abe3a..32b7570 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ const { APP_NAME, IPC, WINDOW, LOG, ARGV_BATCH_DEBOUNCE_MS, SETTINGS_DEFAULTS } const formats = require("./core/formats"); const { createJob, validateJob, STATUS } = require("./core/job"); const { JobRunner, defaultConcurrency } = require("./core/runner"); +const { buildArgs } = require("./core/ffmpeg-args"); const probe = require("./core/probe"); const paths = require("./core/paths"); const scan = require("./core/scan"); @@ -454,8 +455,27 @@ ipcMain.handle(IPC.APP_GET_FORMATS, () => ({ targetsByExt: Object.fromEntries( formats.SUPPORTED_EXTENSIONS.map(ext => [ext, formats.targetsFor(ext)]) ), + // Capability descriptors, so the New Job dialog can offer exactly the + // codecs and quality ranges each container supports rather than hardcoding + // lists that would drift from the format graph. + descriptors: formats.FORMATS, + modes: formats.MODE, + kinds: formats.KIND, })); +// Live ffmpeg preview for the New Job dialog. Runs the spec through the real +// argument builder, so what the dialog shows is what will actually be executed. +ipcMain.handle(IPC.JOB_PREVIEW, (_event, spec) => { + try { + const job = createJob(spec); + const validation = validateJob(job); + const args = buildArgs(job, { outputPath: "", progress: false }); + return { ok: validation.valid, errors: validation.errors, args }; + } catch (err) { + return { ok: false, errors: [err.message], args: [] }; + } +}); + ipcMain.handle(IPC.SETTINGS_GET, () => store.get("settings")); ipcMain.handle(IPC.SETTINGS_SET, (_event, settings) => { store.set("settings", { ...store.get("settings"), ...settings }); diff --git a/src/preload.js b/src/preload.js index bce9ad8..b333a25 100644 --- a/src/preload.js +++ b/src/preload.js @@ -18,6 +18,7 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron"); const CH = { JOB_RUN: "job:run", JOB_CANCEL: "job:cancel", + JOB_PREVIEW: "job:preview", JOB_PROGRESS: "job:progress", JOB_STATUS: "job:status", QUEUE_CANCEL_ALL: "queue:cancel-all", @@ -55,6 +56,7 @@ contextBridge.exposeInMainWorld("electronAPI", { // -- Jobs and queue ------------------------------------------------------- runJob: (job) => ipcRenderer.invoke(CH.JOB_RUN, job), cancelJob: (jobId) => ipcRenderer.invoke(CH.JOB_CANCEL, jobId), + previewJob: (spec) => ipcRenderer.invoke(CH.JOB_PREVIEW, spec), cancelAll: () => ipcRenderer.invoke(CH.QUEUE_CANCEL_ALL), setConcurrency: (n) => ipcRenderer.invoke(CH.QUEUE_SET_CONCURRENCY, n), diff --git a/src/renderer.js b/src/renderer.js index 3113f2e..a2f7312 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -25,6 +25,7 @@ const display = window.display; // Populated from main at startup so the format graph has one source of truth. let FORMATS = null; let TARGETS = null; +let DESCRIPTORS = null; const el = {}; @@ -57,6 +58,7 @@ function addFiles(filePaths) { ext, kind: entry.type, targetExt: null, + settings: {}, meta: null, status: "pending", progress: 0, @@ -261,6 +263,18 @@ function buildCard(job) { name.textContent = p.basename(job.filePath); name.title = job.filePath; + const configure = document.createElement("button"); + configure.className = "card-configure"; + configure.title = "Configure this file"; + const configureIcon = document.createElement("span"); + configureIcon.className = "material-icons-round"; + configureIcon.textContent = "tune"; + configure.appendChild(configureIcon); + configure.addEventListener("click", (e) => { + e.stopPropagation(); + openJobModal([job.id]); + }); + const remove = document.createElement("button"); remove.className = "card-remove"; remove.title = "Remove"; @@ -273,12 +287,15 @@ function buildCard(job) { removeJobs([job.id]); }); - head.append(tick, icon, name, remove); + head.append(tick, icon, name, configure, remove); // -- meta const meta = document.createElement("div"); meta.className = "card-meta"; + const settingsLine = document.createElement("div"); + settingsLine.className = "card-settings d-none"; + // -- convert row const convert = document.createElement("div"); convert.className = "card-convert"; @@ -320,7 +337,7 @@ function buildCard(job) { const actions = document.createElement("div"); actions.className = "card-actions d-none"; - card.append(head, meta, convert, status, progress, actions); + card.append(head, meta, settingsLine, convert, status, progress, actions); card.addEventListener("click", (e) => handleCardClick(job.id, e)); paintCard(card, job); @@ -364,6 +381,15 @@ function paintCard(card, job) { meta.classList.toggle("pending", described === null); if (described) meta.title = described; + // Show what has been configured, so a customised job is visibly different. + const settingsLine = card.querySelector(".card-settings"); + const summary = display.summariseSettings(job.settings); + settingsLine.classList.toggle("d-none", !summary); + if (summary) { + settingsLine.textContent = summary; + settingsLine.title = summary; + } + const { text, tone } = display.describeStatus(job); const status = card.querySelector(".card-status"); status.className = `card-status tone-${tone}`; @@ -514,10 +540,337 @@ function renderActionBar() { // than sitting side by side, so only one is ever visible. $("btn-convert").classList.toggle("d-none", converting); $("btn-convert").disabled = ready === 0; + $("btn-new-job").disabled = total === 0 || converting; $("btn-cancel-all").classList.toggle("d-none", !converting); $("btn-clear-all").disabled = converting; } +// -- New Job dialog ---------------------------------------------------------- +// +// Every control is built from the target format's own capabilities, so a +// container is never offered a codec it cannot carry. The dialog edits a +// partial spec: anything left blank is omitted, and createJob fills it from the +// format's defaults rather than the dialog freezing today's defaults in. +// +// It is deliberately not the only way to set a format — the per-card dropdown +// and the bulk bar still work, so the plain "drop a folder, pick a format, go" +// path stays two clicks. + +let jobModal = null; +let modalScope = []; // job ids the dialog is editing +let presets = []; +let previewTimer = null; + +const ENCODER_PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", + "medium", "slow", "slower", "veryslow"]; + +/** Parse "90", "1:30" or "1:02:03" into seconds. Null when empty/invalid. */ +function parseTime(text) { + const raw = String(text ?? "").trim(); + if (!raw) return null; + if (/^\d+(\.\d+)?$/.test(raw)) return Number(raw); + const parts = raw.split(":"); + if (parts.length < 2 || parts.length > 3) return null; + if (!parts.every(p => /^\d+(\.\d+)?$/.test(p.trim()))) return null; + return parts.reduce((acc, part) => acc * 60 + Number(part), 0); +} + +function fillSelect(select, options, current, placeholder) { + select.innerHTML = ""; + if (placeholder !== undefined) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = placeholder; + select.appendChild(opt); + } + for (const o of options) { + const opt = document.createElement("option"); + opt.value = o.value; + opt.textContent = o.label; + select.appendChild(opt); + } + select.value = current ?? ""; + // Assigning a value no option carries leaves selectedIndex at -1, which + // renders as a blank control and reads back as "". Fall back to the first + // entry so a select without a placeholder always shows a real choice. + if (select.selectedIndex === -1 && select.options.length > 0) { + select.selectedIndex = 0; + } +} + +/** Open the dialog for a set of job ids. */ +function openJobModal(ids) { + modalScope = ids.filter(id => jobs.some(j => j.id === id)); + if (modalScope.length === 0) return; + + const scoped = jobs.filter(j => modalScope.includes(j.id)); + $("jm-scope").textContent = scoped.length === 1 + ? `Configuring ${p.basename(scoped[0].filePath)}` + : `Configuring ${display.countOf(scoped.length, "file")}`; + + // Target options are the intersection, exactly as the bulk bar computes it. + const targets = display.commonTargets(scoped.map(j => j.ext), TARGETS); + const sharedTarget = [...new Set(scoped.map(j => j.targetExt))]; + fillTargetSelect($("jm-target"), targets, sharedTarget.length === 1 ? sharedTarget[0] : null); + $("jm-target-note").textContent = targets.length === 0 + ? "These files have no format in common." + : ""; + + // Seed from the first job's existing settings so reopening shows what is set. + const seed = scoped.find(j => j.settings && Object.keys(j.settings).length > 0); + loadSettingsIntoForm(seed ? seed.settings : {}); + + refreshPresets(); + syncModalControls(); + + if (!jobModal) jobModal = new bootstrap.Modal($("job-modal")); + jobModal.show(); +} + +function loadSettingsIntoForm(settings) { + const s = settings ?? {}; + const v = s.video ?? {}, a = s.audio ?? {}, o = s.output ?? {}, t = s.trim ?? {}, i = s.image ?? {}; + + $("jm-routing").value = o.routing ?? "alongside"; + $("jm-outdir").value = o.dir ?? ""; + $("jm-name").value = o.nameTemplate ?? ""; + $("jm-conflict").value = o.onConflict ?? "ask"; + + $("jm-vmode").value = v.mode ?? "encode"; + $("jm-crf").value = v.crf ?? ""; + $("jm-vbitrate").value = v.bitrate ?? ""; + $("jm-quality-mode").value = v.bitrate != null && v.crf == null ? "bitrate" : "crf"; + $("jm-width").value = v.width ?? ""; + $("jm-height").value = v.height ?? ""; + $("jm-fit").value = v.fitMode ?? "contain"; + $("jm-fps").value = v.fps ?? ""; + + $("jm-amode").value = a.mode ?? "encode"; + $("jm-abitrate").value = a.bitrate ?? ""; + $("jm-arate").value = a.sampleRate ?? ""; + $("jm-achannels").value = a.channels ?? ""; + + $("jm-trim-start").value = t.start != null ? display.formatDuration(t.start) ?? t.start : ""; + $("jm-trim-end").value = t.end != null ? display.formatDuration(t.end) ?? t.end : ""; + + // Codec and quality selects depend on the target, so they are populated by + // syncModalControls; stash the wanted values for it to apply. + $("jm-vcodec").dataset.wanted = v.codec ?? ""; + $("jm-acodec").dataset.wanted = a.codec ?? ""; + $("jm-encpreset").dataset.wanted = v.preset ?? ""; + $("jm-iquality").dataset.wanted = i.quality ?? ""; +} + +/** Rebuild every capability-dependent control for the chosen target. */ +function syncModalControls() { + const target = $("jm-target").value || null; + const sections = display.applicableSections(target, DESCRIPTORS); + + $("jm-video-section").classList.toggle("d-none", !sections.video); + $("jm-audio-section").classList.toggle("d-none", !sections.audio); + $("jm-image-section").classList.toggle("d-none", !sections.image); + $("jm-trim-section").classList.toggle("d-none", !sections.trim); + + // Stream modes: copy is only offered where the container supports remuxing. + const canCopy = display.canStreamCopy(target, DESCRIPTORS); + const modeOptions = (other) => [ + { value: "encode", label: "Re-encode" }, + ...(canCopy ? [{ value: "copy", label: "Copy without re-encoding" }] : []), + { value: "drop", label: other }, + ]; + fillSelect($("jm-vmode"), modeOptions("Remove video"), $("jm-vmode").value); + fillSelect($("jm-amode"), modeOptions("Remove audio"), $("jm-amode").value); + + fillSelect($("jm-vcodec"), display.codecOptions(target, DESCRIPTORS, "video"), + $("jm-vcodec").dataset.wanted || undefined, "Format default"); + fillSelect($("jm-acodec"), display.codecOptions(target, DESCRIPTORS, "audio"), + $("jm-acodec").dataset.wanted || undefined, "Format default"); + + // "Format default" in the codec select means the container's own default, + // not "no codec" — resolve it, or CRF gets disabled and silently dropped + // for every job left on the default. + const codec = effectiveVideoCodec(); + const supportsPreset = ["libx264", "libx265", "libsvtav1"].includes(codec); + fillSelect($("jm-encpreset"), ENCODER_PRESETS.map(x => ({ value: x, label: x })), + $("jm-encpreset").dataset.wanted || undefined, "Encoder default"); + $("jm-encpreset").disabled = !supportsPreset; + $("jm-encpreset").parentElement.classList.toggle("d-none", !supportsPreset); + + const usesCrf = ["libx264", "libx265", "libsvtav1", "libvpx-vp9", "libvpx"].includes(codec); + $("jm-crf-note").textContent = usesCrf + ? "Lower is better quality and a bigger file. 18–28 is the usual range." + : (codec ? "This codec uses a target bitrate rather than constant quality." : ""); + // Switching to a codec with no constant-quality mode forces bitrate. Coming + // back to one that has it must restore constant quality — otherwise the + // control stays stuck on bitrate and the CRF the user typed is discarded + // on apply. Test the disabled flag before overwriting it. + if (!usesCrf) { + $("jm-quality-mode").value = "bitrate"; + } else if ($("jm-quality-mode").disabled) { + $("jm-quality-mode").value = "crf"; + } + $("jm-quality-mode").disabled = !usesCrf; + + const byBitrate = $("jm-quality-mode").value === "bitrate"; + $("jm-crf").classList.toggle("d-none", byBitrate); + $("jm-vbitrate").classList.toggle("d-none", !byBitrate); + $("jm-vbitrate-unit").classList.toggle("d-none", !byBitrate); + + // Encode-only controls hide when the stream is copied or dropped. + const vEncoding = $("jm-vmode").value === "encode"; + for (const el of document.querySelectorAll(".jm-vencode")) el.classList.toggle("d-none", !vEncoding); + const aEncoding = $("jm-amode").value === "encode"; + for (const el of document.querySelectorAll(".jm-aencode")) el.classList.toggle("d-none", !aEncoding); + + // Image quality range comes from the format's own descriptor. + const q = display.qualityDescriptor(target, DESCRIPTORS); + if (q) { + const slider = $("jm-iquality"); + slider.min = q.min; + slider.max = q.max; + slider.step = 1; + const wanted = slider.dataset.wanted; + slider.value = wanted !== "" && wanted !== undefined ? wanted : q.default; + $("jm-iquality-value").textContent = slider.value; + $("jm-iquality-note").textContent = q.inverted + ? `${q.min} is best quality, ${q.max} is smallest file.` + : `${q.max} is best quality, ${q.min} is smallest file.`; + } + + // The output folder only matters when a folder was chosen. + const needsDir = $("jm-routing").value !== "alongside"; + $("jm-outdir").parentElement.parentElement.classList.toggle("d-none", !needsDir); + + schedulePreview(); +} + +/** + * The codec that will actually be used: whatever is chosen, or the target + * format's own default when the select is left on "Format default". + */ +function effectiveVideoCodec() { + const chosen = $("jm-vcodec").value; + if (chosen) return chosen; + const target = $("jm-target").value; + return (DESCRIPTORS && DESCRIPTORS[target] && DESCRIPTORS[target].defaultVideoCodec) || null; +} + +/** Read the form back into a partial job spec. */ +function readSettingsFromForm() { + const target = $("jm-target").value || null; + const sections = display.applicableSections(target, DESCRIPTORS); + const num = (id) => { const v = $(id).value; return v === "" ? null : Number(v); }; + // Match syncModalControls: quality mode is only meaningful once the + // effective codec is known. + const usesCrf = ["libx264", "libx265", "libsvtav1", "libvpx-vp9", "libvpx"] + .includes(effectiveVideoCodec()); + const byBitrate = !usesCrf || $("jm-quality-mode").value === "bitrate"; + + const settings = { + output: { + routing: $("jm-routing").value, + dir: $("jm-outdir").value || null, + nameTemplate: $("jm-name").value || null, + onConflict: $("jm-conflict").value, + }, + video: sections.video ? { + mode: $("jm-vmode").value, + codec: $("jm-vcodec").value || null, + crf: byBitrate ? null : num("jm-crf"), + bitrate: byBitrate ? num("jm-vbitrate") : null, + preset: $("jm-encpreset").disabled ? null : ($("jm-encpreset").value || null), + width: num("jm-width"), + height: num("jm-height"), + fitMode: $("jm-fit").value, + fps: num("jm-fps"), + } : null, + audio: sections.audio ? { + mode: $("jm-amode").value, + codec: $("jm-acodec").value || null, + bitrate: num("jm-abitrate"), + sampleRate: num("jm-arate"), + channels: num("jm-achannels"), + } : null, + image: sections.image ? { quality: num("jm-iquality") } : null, + trim: sections.trim ? { + start: parseTime($("jm-trim-start").value), + end: parseTime($("jm-trim-end").value), + } : null, + }; + + // Defaults that were never touched are dropped, so the format keeps + // control of anything the user did not explicitly choose. + if (settings.output.routing === "alongside") settings.output.routing = null; + if (settings.output.onConflict === "ask") settings.output.onConflict = null; + if (settings.video && settings.video.mode === "encode") settings.video.mode = null; + if (settings.video && settings.video.fitMode === "contain") settings.video.fitMode = null; + if (settings.audio && settings.audio.mode === "encode") settings.audio.mode = null; + + return display.compactSettings(settings); +} + +/** Debounced live preview, built by the real argument builder in main. */ +function schedulePreview() { + clearTimeout(previewTimer); + previewTimer = setTimeout(async () => { + const target = $("jm-target").value; + const sample = jobs.find(j => modalScope.includes(j.id)); + if (!target || !sample) { + $("jm-preview").textContent = "Choose a format to see the command."; + $("jm-errors").textContent = ""; + return; + } + const result = await api.previewJob({ + inputPath: sample.filePath, + targetExt: target, + ...readSettingsFromForm(), + }); + $("jm-preview").textContent = `ffmpeg ${result.args.join(" ")}`; + $("jm-errors").textContent = result.ok ? "" : result.errors.join(" "); + $("jm-apply").disabled = !result.ok; + }, 180); +} + +function applyJobModal() { + const target = $("jm-target").value || null; + const settings = readSettingsFromForm(); + + for (const job of jobs) { + if (!modalScope.includes(job.id)) continue; + if (job.status === "running") continue; + job.settings = settings; + updateCard(job); + } + if (target) setTarget(modalScope, target); + + jobModal.hide(); + const n = modalScope.length; + toast(`Settings applied to ${display.countOf(n, "file")}.`, "success"); +} + +// -- Presets ----------------------------------------------------------------- + +async function refreshPresets() { + presets = await api.listPresets(); + fillSelect($("jm-preset"), presets.map(x => ({ value: x.id, label: x.name })), "", "No preset"); + $("jm-preset-delete").disabled = true; +} + +async function savePresetFromForm() { + const name = String(window.prompt("Preset name") ?? "").trim(); + if (!name) return; + const preset = { + id: `p${Date.now().toString(36)}`, + name, + targetExt: $("jm-target").value || null, + settings: readSettingsFromForm(), + }; + presets = await api.savePreset(preset); + fillSelect($("jm-preset"), presets.map(x => ({ value: x.id, label: x.name })), preset.id, "No preset"); + $("jm-preset-delete").disabled = false; + toast(`Preset "${name}" saved.`, "success"); +} + // -- Toasts ------------------------------------------------------------------ const TOAST_ICONS = { info: "info", success: "check_circle", warning: "warning", danger: "error" }; @@ -579,6 +932,7 @@ async function startConversion() { id: job.id, inputPath: job.filePath, targetExt: job.targetExt, + ...(job.settings ?? {}), }); applyResult(job, result); return result; @@ -626,6 +980,7 @@ function announce(results) { document.addEventListener("DOMContentLoaded", async () => { FORMATS = await api.getFormats(); TARGETS = FORMATS.targetsByExt; + DESCRIPTORS = FORMATS.descriptors; // Drag and drop. The document-level guard stops a stray drop navigating the // window to the file and replacing the app with it. @@ -690,6 +1045,48 @@ document.addEventListener("DOMContentLoaded", async () => { }); $("bulk-remove").addEventListener("click", () => removeJobs([...selection])); $("bulk-deselect").addEventListener("click", clearSelection); + // The corner button configures the selection, or everything when nothing + // is selected — the same scope rule the bulk bar uses. + $("btn-new-job").addEventListener("click", () => { + openJobModal(bulkScope().map(j => j.id)); + }); + + $("jm-target").addEventListener("change", syncModalControls); + for (const id of ["jm-vmode", "jm-amode", "jm-vcodec", "jm-acodec", "jm-quality-mode", "jm-routing"]) { + $(id).addEventListener("change", syncModalControls); + } + for (const id of ["jm-crf", "jm-vbitrate", "jm-width", "jm-height", "jm-fit", "jm-fps", + "jm-abitrate", "jm-arate", "jm-achannels", "jm-encpreset", + "jm-trim-start", "jm-trim-end", "jm-name", "jm-conflict"]) { + $(id).addEventListener("input", schedulePreview); + $(id).addEventListener("change", schedulePreview); + } + $("jm-iquality").addEventListener("input", (e) => { + $("jm-iquality-value").textContent = e.target.value; + schedulePreview(); + }); + $("jm-outdir-browse").addEventListener("click", async () => { + const dir = await api.chooseOutput(); + if (dir) { $("jm-outdir").value = dir; schedulePreview(); } + }); + $("jm-apply").addEventListener("click", applyJobModal); + $("jm-preset-save").addEventListener("click", savePresetFromForm); + $("jm-preset-delete").addEventListener("click", async () => { + const id = $("jm-preset").value; + if (!id) return; + presets = await api.deletePreset(id); + await refreshPresets(); + toast("Preset deleted.", "info"); + }); + $("jm-preset").addEventListener("change", (e) => { + const preset = presets.find(x => x.id === e.target.value); + $("jm-preset-delete").disabled = !preset; + if (!preset) return; + if (preset.targetExt) $("jm-target").value = preset.targetExt; + loadSettingsIntoForm(preset.settings); + syncModalControls(); + }); + $("bulk-select-all").addEventListener("change", (e) => { if (e.target.checked) selectAll(); else clearSelection(); diff --git a/src/styles.css b/src/styles.css index 6d90c08..e05a4b6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -471,6 +471,84 @@ body.popup-window span:hover { animation: spin 1.4s linear infinite; } +/* ── New Job dialog ──────────────────────────────────────────────────────── */ + +.jm-section { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--bs-secondary-color); + margin: 1.25rem 0 0.6rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.jm-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 0.75rem 1rem; +} + +.jm-row .form-label { + font-size: 0.8rem; + margin-bottom: 0.25rem; +} + +/* Reserve the help line so the dialog does not jump as notes appear. */ +.jm-row .form-text { + min-height: 1.2em; + margin-top: 0.2rem; +} + +.jm-preview { + background-color: var(--bs-secondary-bg); + border: 1px solid var(--bs-border-color); + border-radius: var(--radius-control); + padding: 0.6rem 0.75rem; + font-size: 0.75rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + margin: 0; + max-height: 8rem; + overflow-y: auto; + /* The one place selection is wanted: a command you may want to copy. */ + user-select: text; +} + +.jm-preview::-webkit-scrollbar { width: var(--scrollbar-width); } +.jm-preview::-webkit-scrollbar-track { background: transparent; } +.jm-preview::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } + +.modal-body::-webkit-scrollbar { width: var(--scrollbar-width); } +.modal-body::-webkit-scrollbar-track { background: transparent; } +.modal-body::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } + +/* Card gains a configure button and a settings summary line. */ +.card-configure { + border: none; + background: none; + color: var(--bs-secondary-color); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + flex-shrink: 0; + transition: color var(--dur-micro) var(--ease-micro); +} + +.card-configure:hover { color: var(--accent); } +.card-configure .material-icons-round { font-size: 16px; line-height: 1; display: block; } + +.card-settings { + font-size: 0.72rem; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Toast ───────────────────────────────────────────────────────────────── */ /* The house spec puts this at top:16px, which suits a web page whose content diff --git a/test/display.test.js b/test/display.test.js index a10246d..5dc4971 100644 --- a/test/display.test.js +++ b/test/display.test.js @@ -338,3 +338,121 @@ test("every counted string in the renderer goes through the helper", () => { assert.deepEqual(raw.map(m => m[0]), [], "found a raw count followed by a plural noun — route it through display.countOf"); }); + +// --------------------------------------------------------------------------- +// New Job dialog +// +// The dialog is driven entirely from the format capability graph, so an +// invalid combination is unreachable rather than merely rejected on submit. +// --------------------------------------------------------------------------- + +const DESCRIPTORS = formats.FORMATS; + +test("dialog: an audio target offers no video controls", () => { + const s = d.applicableSections("mp3", DESCRIPTORS); + assert.equal(s.video, false); + assert.equal(s.audio, true); + assert.equal(s.resize, false); + assert.equal(s.image, false); +}); + +test("dialog: an image target offers no audio controls", () => { + const s = d.applicableSections("png", DESCRIPTORS); + assert.equal(s.audio, false); + assert.equal(s.image, true, "a still image exposes its own quality knob"); + assert.equal(s.trim, false, "trimming a single still is meaningless"); +}); + +test("dialog: an animated image target can still be trimmed", () => { + assert.equal(d.applicableSections("gif", DESCRIPTORS).trim, true); + assert.equal(d.applicableSections("webp", DESCRIPTORS).trim, true); +}); + +test("dialog: a video target offers everything", () => { + const s = d.applicableSections("mp4", DESCRIPTORS); + assert.equal(s.video, true); + assert.equal(s.audio, true); + assert.equal(s.trim, true); + assert.equal(s.resize, true); +}); + +test("dialog: an unknown target offers nothing rather than throwing", () => { + const s = d.applicableSections("nope", DESCRIPTORS); + assert.deepEqual(s, { video: false, audio: false, image: false, trim: false, resize: false }); +}); + +test("dialog: codec choices come from the container's own capabilities", () => { + const mp4 = d.codecOptions("mp4", DESCRIPTORS, "video").map(o => o.value); + assert.ok(mp4.includes("libx264")); + assert.ok(!mp4.includes("libvpx-vp9"), "MP4 must not offer VP9"); + + const webm = d.codecOptions("webm", DESCRIPTORS, "video").map(o => o.value); + assert.ok(webm.includes("libvpx-vp9")); + assert.ok(!webm.includes("libx264"), "WebM must not offer H.264"); + + // Every offered codec must be one validateJob would accept. + for (const ext of formats.SUPPORTED_EXTENSIONS) { + for (const stream of ["video", "audio"]) { + for (const opt of d.codecOptions(ext, DESCRIPTORS, stream)) { + const list = stream === "audio" ? DESCRIPTORS[ext].audioCodecs : DESCRIPTORS[ext].videoCodecs; + assert.ok(list.includes(opt.value), `${ext} offered ${opt.value} for ${stream}`); + } + } + } +}); + +test("dialog: codecs get readable labels, unknown ones pass through", () => { + assert.equal(d.CODEC_LABELS.libx264, "H.264"); + assert.equal(d.codecOptions("mp4", DESCRIPTORS, "audio")[0].label, "AAC"); +}); + +test("dialog: stream copy is offered only where the container supports it", () => { + assert.equal(d.canStreamCopy("mp4", DESCRIPTORS), true); + assert.equal(d.canStreamCopy("png", DESCRIPTORS), false); + assert.equal(d.canStreamCopy("nope", DESCRIPTORS), false); +}); + +test("dialog: image quality descriptors carry their real range", () => { + const jpg = d.qualityDescriptor("jpg", DESCRIPTORS); + assert.equal(jpg.flag, "-q:v"); + assert.equal(jpg.inverted, true, "JPEG quality runs backwards"); + + const webp = d.qualityDescriptor("webp", DESCRIPTORS); + assert.equal(webp.max, 100); + assert.equal(d.qualityDescriptor("mp4", DESCRIPTORS), null); +}); + +test("dialog: settings summary describes only what was changed", () => { + assert.equal(d.summariseSettings(null), null); + assert.equal(d.summariseSettings({}), null, "an untouched job has no summary"); + + assert.equal( + d.summariseSettings({ video: { codec: "libx264", crf: 20, width: 1280, height: 720 } }), + "H.264 · CRF 20 · 1280×720" + ); + assert.equal(d.summariseSettings({ video: { mode: "copy" } }), "copy video"); + assert.equal(d.summariseSettings({ audio: { mode: "drop" } }), "no audio"); + assert.equal(d.summariseSettings({ audio: { codec: "aac", bitrate: 192, channels: 1 } }), "AAC · 192k · mono"); + assert.equal(d.summariseSettings({ image: { quality: 3 } }), "q3"); + assert.equal(d.summariseSettings({ trim: { start: 5, end: 65 } }), "trim 0:05–1:05"); + assert.equal(d.summariseSettings({ trim: { start: 5 } }), "trim 0:05–end"); + assert.equal(d.summariseSettings({ output: { nameTemplate: "{name}-web" } }), "renamed"); + assert.equal(d.summariseSettings({ output: { nameTemplate: "{name}" } }), null, "the default is not a change"); +}); + +test("dialog: compacting drops blanks so format defaults still apply", () => { + const compacted = d.compactSettings({ + video: { codec: "libx264", crf: null, bitrate: "", preset: "slow" }, + audio: { codec: null, bitrate: null }, + trim: { start: null, end: null }, + }); + assert.deepEqual(compacted, { video: { codec: "libx264", preset: "slow" } }); + assert.deepEqual(d.compactSettings({}), {}); + assert.deepEqual(d.compactSettings(null), {}); +}); + +test("dialog: a compacted spec keeps zero, which is a real value", () => { + // CRF 0 is lossless — dropping it as falsy would silently change the job. + assert.deepEqual(d.compactSettings({ video: { crf: 0 } }), { video: { crf: 0 } }); + assert.deepEqual(d.compactSettings({ trim: { start: 0 } }), { trim: { start: 0 } }); +}); From 49fd4219d26ecb17171f90e1aa3113c93c7133b3 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 28 Aug 2026 16:36:25 +0100 Subject: [PATCH 11/81] refactor: drop "job" from the interface, move bulk edit into the selection bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the advanced settings dialog. **"Job" is gone from the interface.** One card opens **Advanced options**; a selection opens **Bulk edit**. The dialog titles itself according to how it was opened. Internal identifiers keep the word — core/job.js, JobRunner, the job:* channels, .job-card — because renaming them is a large diff with no user benefit and real regression risk, and none of it is visible. That is recorded as a rename-only commit if it is ever wanted. **Bulk edit moved out of the corner and into the green selection bar.** The bar already answers "what am I acting on", so the action that acts on it belongs beside the format dropdown rather than in a separate corner. It follows the same scope rule: the selection, or everything when nothing is selected, where it reads "Edit all". Options are already restricted to what every file in scope supports, since the target list is the intersection and every codec, quality and section is derived from that shared target. **"All 1 file" became "Select all"**, which is what the checkbox beside it actually does. **Trim is a two-point slider.** Two text boxes never showed the useful thing, which is where the kept span sits relative to the whole clip. Handles cannot cross, the kept duration is shown between them, and holding Shift while dragging scales pointer movement down for fine adjustment. Arrow keys move a handle, Shift+arrow moves it less, Home and End jump to the extremes. A file with no readable duration says it cannot be trimmed rather than presenting a slider with nothing to slide over, and a bulk edit spans the longest clip in the selection. **The dialog's Cancel was filled.** A neutral action beside a filled primary is outlined — two filled buttons side by side read as two equal choices, which is exactly what a cancel is not. The design system said the opposite, because Bootstrap's own modal example uses a filled btn-secondary and that is what the Dropgate client copied; the buttons and modal artboards now carry the rule and a correct/wrong pair. Bulk editing files with differing settings now starts blank and says so, rather than presenting one file's values as if they applied to all. --- docs/MANUAL-TESTING.md | 43 ++++- docs/design-system/build.js | 20 ++- docs/design-system/components/buttons.html | 15 ++ docs/design-system/components/modal.html | 5 +- src/index.html | 34 ++-- src/renderer.js | 183 +++++++++++++++++---- src/styles.css | 62 +++++++ 7 files changed, 307 insertions(+), 55 deletions(-) diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index 674c42d..0341552 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -270,7 +270,7 @@ The app currently ships as `2.0.0-alpha.1` while the latest stable is `1.0.0`. --- -## 2.0.0-alpha.2 — New Job dialog +## 2.0.0-alpha.2 — Advanced options / Bulk edit Per-file encoding settings. The engine already supported all of this; this pass exposes it. **The plain path must stay two clicks** — check that first. @@ -283,13 +283,18 @@ exposes it. **The plain path must stay two clicks** — check that first. ### Opening the dialog -- [ ] **New Job** in the top-right is disabled with an empty grid, enabled once - files are queued, and disabled again while a conversion runs. -- [ ] With nothing selected it opens for every file and the subtitle reads - "Configuring N files". -- [ ] With cards selected it opens for just those. -- [ ] The tune icon on a card opens it for that one file only, and does not - change the selection. +- [ ] There is **no button in the top-right corner** for this — it lives in the + green selection bar. +- [ ] With nothing selected the bar reads **"Select all"** and the button reads + **"Edit all"**; it opens for every file. +- [ ] With cards selected the button reads **"Bulk edit"** and opens for just + those, titled **"Bulk edit"**. +- [ ] The tune icon on a card opens it for that one file only, titled + **"Advanced options"**, and does not change the selection. +- [ ] Bulk editing files that already have *different* settings starts blank and + says they are configured differently, rather than showing one file's + values as if they applied to all. +- [ ] The word "job" appears nowhere in the interface. - [ ] Escape and the × both close it without applying anything. ### Controls follow the target format @@ -319,6 +324,28 @@ exposes it. **The plain path must stay two clicks** — check that first. exception to nothing-is-selectable.)* - [ ] An invalid combination shows a red message and disables **Apply**. +### Trim slider + +- [ ] Trim is a **two-point slider**, not a pair of text boxes. +- [ ] Drag the left handle right and the right handle left; the green range + between them shrinks and the readout shows the kept duration. +- [ ] The handles **cannot cross**. +- [ ] **Hold Shift while dragging** — the handle moves much more slowly for the + same pointer movement, so a precise point is reachable. +- [ ] Click a handle and use the arrow keys; Shift+arrow gives a smaller step. + Home and End jump to the extremes. +- [ ] Leave the handles alone and Apply: **no trim is recorded**, and the card + shows no trim in its summary. +- [ ] Open advanced options on a file with no readable duration: the slider is + replaced by a note saying it cannot be trimmed. +- [ ] Bulk edit a selection of clips of different lengths: the slider spans the + **longest**, and a trim past a shorter clip's end simply runs to its end. + +### Button styling + +- [ ] The dialog's **Cancel is outlined, not filled** — only Apply is filled. + Two filled buttons side by side is a design violation. + ### Applying - [ ] Apply, then check the card: a green summary line appears under the diff --git a/docs/design-system/build.js b/docs/design-system/build.js index c263604..9973400 100644 --- a/docs/design-system/build.js +++ b/docs/design-system/build.js @@ -302,6 +302,21 @@ card("components/buttons.html", "Buttons", disabledopacity .5 + pointer-events:none +

Fill is reserved for the action being committed

+
+
+ correct
+
+ wrong
+

A neutral action sitting beside a filled primary is outlined, not filled. + Two filled buttons side by side read as two equal choices, which is exactly what a + cancel is not. This applies anywhere the pair appears — dialog footers, the action bar, + card actions. Bootstrap's own modal example uses a filled btn-secondary for + Cancel; the house does not.

+

A filled neutral button is still fine when it stands alone with no primary + beside it, as with Add Files in the toolbar.

+
+

Swap, do not crowd

→ becomes →
@@ -544,6 +559,7 @@ card("components/modal.html", "Modal Dialog", .mfoot{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px 16px} .btn{padding:7px 14px;font-size:14px;border-radius:5px;border:1px solid transparent;cursor:pointer;font-family:inherit} .b-sec{background:#6c757d;color:#fff} +.b-out{background:transparent;border-color:#6c757d;color:var(--text-dim)} .b-warn{background:var(--warning);color:#000}
@@ -554,7 +570,7 @@ card("components/modal.html", "Modal Dialog",

The server administrator may be able to access your file contents.

- +
@@ -567,7 +583,7 @@ card("components/modal.html", "Modal Dialog", Dialogmodal-dialog-centered TitleSemantic icon + text in d-flex align-items-center gap-2 BodyOne plain statement, then one text-body-secondary small mb-0 caveat -FooterNeutral cancel on the left, semantic confirm on the right +FooterOutlined neutral cancel on the left, filled semantic confirm on the right — never two filled buttons BackdropBootstrap default — #000 at .5 MotionBootstrap default — backdrop .15s linear, dialog translate(0,-50px) → 0 over .3s ease-out diff --git a/docs/design-system/components/buttons.html b/docs/design-system/components/buttons.html index 5f821b0..3524c94 100644 --- a/docs/design-system/components/buttons.html +++ b/docs/design-system/components/buttons.html @@ -71,6 +71,21 @@

Variant vocabulary

disabledopacity .5 + pointer-events:none +

Fill is reserved for the action being committed

+
+
+ correct
+
+ wrong
+

A neutral action sitting beside a filled primary is outlined, not filled. + Two filled buttons side by side read as two equal choices, which is exactly what a + cancel is not. This applies anywhere the pair appears — dialog footers, the action bar, + card actions. Bootstrap's own modal example uses a filled btn-secondary for + Cancel; the house does not.

+

A filled neutral button is still fine when it stands alone with no primary + beside it, as with Add Files in the toolbar.

+
+

Swap, do not crowd

→ becomes →
diff --git a/docs/design-system/components/modal.html b/docs/design-system/components/modal.html index 88dc89c..1ec33ef 100644 --- a/docs/design-system/components/modal.html +++ b/docs/design-system/components/modal.html @@ -55,6 +55,7 @@

Modal Dialog

.mfoot{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px 16px} .btn{padding:7px 14px;font-size:14px;border-radius:5px;border:1px solid transparent;cursor:pointer;font-family:inherit} .b-sec{background:#6c757d;color:#fff} +.b-out{background:transparent;border-color:#6c757d;color:var(--text-dim)} .b-warn{background:var(--warning);color:#000}
@@ -65,7 +66,7 @@

Modal Dialog

The server administrator may be able to access your file contents.

- +
@@ -78,7 +79,7 @@

Conventions

Dialogmodal-dialog-centered TitleSemantic icon + text in d-flex align-items-center gap-2 BodyOne plain statement, then one text-body-secondary small mb-0 caveat -FooterNeutral cancel on the left, semantic confirm on the right +FooterOutlined neutral cancel on the left, filled semantic confirm on the right — never two filled buttons BackdropBootstrap default — #000 at .5 MotionBootstrap default — backdrop .15s linear, dialog translate(0,-50px) → 0 over .3s ease-out diff --git a/src/index.html b/src/index.html index 2b2c26b..39c6d4d 100644 --- a/src/index.html +++ b/src/index.html @@ -30,9 +30,6 @@

Diamond File Converter

-
@@ -55,6 +52,9 @@

Diamond File Converter

+
@@ -96,7 +96,7 @@

Diamond File Converter

@@ -255,17 +255,25 @@
Image
Trim
-
-
- - +
+
+
+
+
+
-
- - +
+ 0:00 + + 0:00
+
Drag either end. Hold Shift while dragging for finer control.
+
+
+ Duration is not known for this file, so it cannot be trimmed.
-
Seconds, or m:ss / h:mm:ss.
Command preview
@@ -274,7 +282,7 @@
Command preview
diff --git a/src/renderer.js b/src/renderer.js index a2f7312..3234ecd 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -265,7 +265,7 @@ function buildCard(job) { const configure = document.createElement("button"); configure.className = "card-configure"; - configure.title = "Configure this file"; + configure.title = "Advanced options"; const configureIcon = document.createElement("span"); configureIcon.className = "material-icons-round"; configureIcon.textContent = "tune"; @@ -477,9 +477,7 @@ function renderSelection() { const scope = bulkScope(); const everything = count === 0; - $("bulk-count").textContent = everything - ? `All ${display.countOf(jobs.length, "file")}` - : `${count} selected`; + $("bulk-count").textContent = everything ? "Select all" : `${count} selected`; $("bulk-target-label").textContent = everything ? "Convert all to" : "Convert selected to"; const selectAllBox = $("bulk-select-all"); @@ -488,6 +486,7 @@ function renderSelection() { $("bulk-remove").classList.toggle("d-none", everything); $("bulk-deselect").classList.toggle("d-none", everything); + $("bulk-edit-label").textContent = everything ? "Edit all" : "Bulk edit"; // Offer only formats every source in scope can actually produce, so a bulk // change can never create an invalid job. @@ -540,7 +539,7 @@ function renderActionBar() { // than sitting side by side, so only one is ever visible. $("btn-convert").classList.toggle("d-none", converting); $("btn-convert").disabled = ready === 0; - $("btn-new-job").disabled = total === 0 || converting; + $("bulk-edit").disabled = converting; $("btn-cancel-all").classList.toggle("d-none", !converting); $("btn-clear-all").disabled = converting; } @@ -564,15 +563,131 @@ let previewTimer = null; const ENCODER_PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"]; -/** Parse "90", "1:30" or "1:02:03" into seconds. Null when empty/invalid. */ -function parseTime(text) { - const raw = String(text ?? "").trim(); - if (!raw) return null; - if (/^\d+(\.\d+)?$/.test(raw)) return Number(raw); - const parts = raw.split(":"); - if (parts.length < 2 || parts.length > 3) return null; - if (!parts.every(p => /^\d+(\.\d+)?$/.test(p.trim()))) return null; - return parts.reduce((acc, part) => acc * 60 + Number(part), 0); +// -- Trim slider ------------------------------------------------------------- +// +// A two-point slider rather than a pair of text boxes: the useful thing about a +// trim is where the kept span sits relative to the whole clip, which a pair of +// numbers does not show. Holding Shift scales pointer movement down for fine +// adjustment, and the arrow keys do the same with a smaller step. + +const trimState = { duration: 0, start: 0, end: 0, pendingEnd: null, available: false }; + +const TRIM_MIN_SPAN = 0.05; // never let the two handles cross +const TRIM_KEY_STEP = 1; // seconds per arrow press +const TRIM_FINE = 0.15; // Shift multiplier while dragging + +/** Longest duration in scope — a selection may hold clips of different lengths. */ +function trimScopeDuration() { + const scoped = jobs.filter(j => modalScope.includes(j.id)); + const durations = scoped + .map(j => (j.meta && j.meta.ok ? j.meta.duration : null)) + .filter(d => typeof d === "number" && d > 0); + return durations.length > 0 ? Math.max(...durations) : 0; +} + +function setupTrim() { + const duration = trimScopeDuration(); + trimState.duration = duration; + trimState.available = duration > 0; + + $("jm-trim-wrap").classList.toggle("d-none", !trimState.available); + $("jm-trim-unavailable").classList.toggle("d-none", trimState.available); + if (!trimState.available) return; + + trimState.start = Math.min(trimState.start ?? 0, duration); + trimState.end = trimState.pendingEnd != null + ? Math.min(trimState.pendingEnd, duration) + : duration; + trimState.pendingEnd = null; + renderTrim(); +} + +function renderTrim() { + const { duration, start, end } = trimState; + if (!duration) return; + const pct = (v) => `${(v / duration) * 100}%`; + + $("jm-trim-thumb-start").style.left = pct(start); + $("jm-trim-thumb-end").style.left = pct(end); + $("jm-trim-range").style.left = pct(start); + $("jm-trim-range").style.width = pct(end - start); + + $("jm-trim-start-label").textContent = display.formatDuration(start) ?? "0:00"; + $("jm-trim-end-label").textContent = display.formatDuration(end) ?? ""; + $("jm-trim-kept").textContent = `keeping ${display.formatDuration(end - start) ?? "0:00"}`; + + for (const [id, value] of [["jm-trim-thumb-start", start], ["jm-trim-thumb-end", end]]) { + const thumb = $(id); + thumb.setAttribute("aria-valuemin", "0"); + thumb.setAttribute("aria-valuemax", String(Math.round(duration))); + thumb.setAttribute("aria-valuenow", String(Math.round(value))); + thumb.setAttribute("aria-valuetext", display.formatDuration(value) ?? ""); + } +} + +/** Read the slider back as a spec, omitting bounds that were never moved. */ +function readTrim() { + if (!trimState.available) return null; + const { duration, start, end } = trimState; + return { + start: start > 0 ? Math.round(start * 100) / 100 : null, + end: end < duration ? Math.round(end * 100) / 100 : null, + }; +} + +function moveTrimHandle(handle, seconds) { + const { duration } = trimState; + if (handle === "start") { + trimState.start = Math.max(0, Math.min(seconds, trimState.end - TRIM_MIN_SPAN)); + } else { + trimState.end = Math.min(duration, Math.max(seconds, trimState.start + TRIM_MIN_SPAN)); + } + renderTrim(); + schedulePreview(); +} + +function initTrimSlider() { + const slider = $("jm-trim-slider"); + + for (const handle of ["start", "end"]) { + const thumb = $(`jm-trim-thumb-${handle}`); + + thumb.addEventListener("pointerdown", (e) => { + if (!trimState.available) return; + e.preventDefault(); + thumb.setPointerCapture(e.pointerId); + const originX = e.clientX; + const origin = handle === "start" ? trimState.start : trimState.end; + + const onMove = (ev) => { + const width = slider.getBoundingClientRect().width || 1; + // Shift shrinks how far the value travels per pixel, which is + // what "finer control" means for a drag. + const scale = ev.shiftKey ? TRIM_FINE : 1; + slider.classList.toggle("fine", ev.shiftKey); + const delta = ((ev.clientX - originX) / width) * trimState.duration * scale; + moveTrimHandle(handle, origin + delta); + }; + const onUp = (ev) => { + thumb.releasePointerCapture(ev.pointerId); + thumb.removeEventListener("pointermove", onMove); + thumb.removeEventListener("pointerup", onUp); + slider.classList.remove("fine"); + }; + thumb.addEventListener("pointermove", onMove); + thumb.addEventListener("pointerup", onUp); + }); + + thumb.addEventListener("keydown", (e) => { + if (!trimState.available) return; + const step = e.shiftKey ? TRIM_KEY_STEP * TRIM_FINE : TRIM_KEY_STEP; + const current = handle === "start" ? trimState.start : trimState.end; + if (e.key === "ArrowLeft") { e.preventDefault(); moveTrimHandle(handle, current - step); } + else if (e.key === "ArrowRight") { e.preventDefault(); moveTrimHandle(handle, current + step); } + else if (e.key === "Home") { e.preventDefault(); moveTrimHandle(handle, 0); } + else if (e.key === "End") { e.preventDefault(); moveTrimHandle(handle, trimState.duration); } + }); + } } function fillSelect(select, options, current, placeholder) { @@ -604,9 +719,12 @@ function openJobModal(ids) { if (modalScope.length === 0) return; const scoped = jobs.filter(j => modalScope.includes(j.id)); - $("jm-scope").textContent = scoped.length === 1 - ? `Configuring ${p.basename(scoped[0].filePath)}` - : `Configuring ${display.countOf(scoped.length, "file")}`; + const single = scoped.length === 1; + + $("jm-title").textContent = single ? "Advanced options" : "Bulk edit"; + $("jm-scope").textContent = single + ? p.basename(scoped[0].filePath) + : `Applies to ${display.countOf(scoped.length, "file")}`; // Target options are the intersection, exactly as the bulk bar computes it. const targets = display.commonTargets(scoped.map(j => j.ext), TARGETS); @@ -616,9 +734,13 @@ function openJobModal(ids) { ? "These files have no format in common." : ""; - // Seed from the first job's existing settings so reopening shows what is set. - const seed = scoped.find(j => j.settings && Object.keys(j.settings).length > 0); - loadSettingsIntoForm(seed ? seed.settings : {}); + // Seed from existing settings so reopening shows what is set. Across a + // selection this only happens when every file already agrees — otherwise + // one file's values would be presented as if they applied to all. + const encoded = scoped.map(j => JSON.stringify(j.settings ?? {})); + const uniform = encoded.every(x => x === encoded[0]); + loadSettingsIntoForm(uniform ? (scoped[0].settings ?? {}) : {}); + $("jm-scope").textContent += uniform ? "" : " · these files are currently configured differently"; refreshPresets(); syncModalControls(); @@ -650,8 +772,8 @@ function loadSettingsIntoForm(settings) { $("jm-arate").value = a.sampleRate ?? ""; $("jm-achannels").value = a.channels ?? ""; - $("jm-trim-start").value = t.start != null ? display.formatDuration(t.start) ?? t.start : ""; - $("jm-trim-end").value = t.end != null ? display.formatDuration(t.end) ?? t.end : ""; + trimState.start = t.start ?? 0; + trimState.pendingEnd = t.end ?? null; // Codec and quality selects depend on the target, so they are populated by // syncModalControls; stash the wanted values for it to apply. @@ -737,6 +859,8 @@ function syncModalControls() { : `${q.max} is best quality, ${q.min} is smallest file.`; } + if (sections.trim) setupTrim(); + // The output folder only matters when a folder was chosen. const needsDir = $("jm-routing").value !== "alongside"; $("jm-outdir").parentElement.parentElement.classList.toggle("d-none", !needsDir); @@ -792,10 +916,7 @@ function readSettingsFromForm() { channels: num("jm-achannels"), } : null, image: sections.image ? { quality: num("jm-iquality") } : null, - trim: sections.trim ? { - start: parseTime($("jm-trim-start").value), - end: parseTime($("jm-trim-end").value), - } : null, + trim: sections.trim ? readTrim() : null, }; // Defaults that were never touched are dropped, so the format keeps @@ -1045,9 +1166,10 @@ document.addEventListener("DOMContentLoaded", async () => { }); $("bulk-remove").addEventListener("click", () => removeJobs([...selection])); $("bulk-deselect").addEventListener("click", clearSelection); - // The corner button configures the selection, or everything when nothing - // is selected — the same scope rule the bulk bar uses. - $("btn-new-job").addEventListener("click", () => { + // Bulk edit lives in the selection bar and follows the same scope rule as + // the format dropdown beside it: the selection, or everything when nothing + // is selected. + $("bulk-edit").addEventListener("click", () => { openJobModal(bulkScope().map(j => j.id)); }); @@ -1055,9 +1177,10 @@ document.addEventListener("DOMContentLoaded", async () => { for (const id of ["jm-vmode", "jm-amode", "jm-vcodec", "jm-acodec", "jm-quality-mode", "jm-routing"]) { $(id).addEventListener("change", syncModalControls); } + initTrimSlider(); for (const id of ["jm-crf", "jm-vbitrate", "jm-width", "jm-height", "jm-fit", "jm-fps", "jm-abitrate", "jm-arate", "jm-achannels", "jm-encpreset", - "jm-trim-start", "jm-trim-end", "jm-name", "jm-conflict"]) { + "jm-name", "jm-conflict"]) { $(id).addEventListener("input", schedulePreview); $(id).addEventListener("change", schedulePreview); } diff --git a/src/styles.css b/src/styles.css index e05a4b6..916023b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -549,6 +549,68 @@ body.popup-window span:hover { white-space: nowrap; } +/* ── Trim slider ─────────────────────────────────────────────────────────── */ + +.trim-slider { + position: relative; + height: 34px; + margin: 0.25rem 0 0.1rem; + touch-action: none; +} + +.trim-track { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 5px; + margin-top: -2.5px; + border-radius: 3px; + background-color: var(--bs-secondary-bg); +} + +/* The kept portion of the clip. */ +.trim-range { + position: absolute; + top: 50%; + height: 5px; + margin-top: -2.5px; + border-radius: 3px; + background-color: var(--accent); +} + +.trim-thumb { + position: absolute; + top: 50%; + width: 15px; + height: 15px; + margin: -7.5px 0 0 -7.5px; + border-radius: 50%; + background-color: var(--accent); + border: 2px solid var(--bs-body-bg); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); + cursor: grab; + transition: transform var(--dur-micro) var(--ease-micro); +} + +.trim-thumb:hover { transform: scale(1.15); } +.trim-thumb:active { cursor: grabbing; transform: scale(1.15); } + +.trim-thumb:focus-visible { + outline: none; + box-shadow: 0 0 0 0.2rem rgba(var(--accent-rgb), 0.35); +} + +/* Held Shift means a finer drag; the cursor says so. */ +.trim-slider.fine .trim-thumb:active { cursor: crosshair; } + +.trim-readout { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + /* ── Toast ───────────────────────────────────────────────────────────────── */ /* The house spec puts this at top:16px, which suits a web page whose content From 26b5d7e1b36a46b3c94e7aaad8bb1f538e53a442 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 28 Aug 2026 17:04:07 +0100 Subject: [PATCH 12/81] fix: stop selects clipping their text, move presets out of advanced options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed max-width on a ` cut the selected option off with no ellipsis and +no scrollbar — the text just disappears. Check at a few window sizes. + +- [ ] In Video, the Quality dropdown reads **"Constant quality"** in full, not + "Constant qual…". +- [ ] The Resize fit dropdown reads **"Contain"** in full, not "Contai". +- [ ] Type `1920` and `1080` into Resize — both are fully readable. +- [ ] Widen and narrow the window: no label, dropdown or field ever cuts text + off mid-word. Filenames on cards are the one exception — those ellipsis + deliberately. +- [ ] Repeat with an audio target and an image target, where different controls + are on screen. ### Output routing diff --git a/src/index.html b/src/index.html index 39c6d4d..417e1f9 100644 --- a/src/index.html +++ b/src/index.html @@ -104,18 +104,6 @@