diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index ac7ae18..271310b 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -24,7 +24,7 @@ sections — the history is useful. ``` npm install -npm test # expect 193 passing, 0 failing +npm test # expect 198 passing, 0 failing npm start ``` @@ -2662,3 +2662,57 @@ Run it in the Linux VM, on a desktop. Close and reopen: the window is where you left it. - [x] `~/.config/diamond-file-converter/debug.log` names files by their names only, with no folder. + +--- + +## 2.0.0-beta.5: card fixes (2026-10-05) + +Three bugs Will found on beta.4, all in the app itself (electron-kit has no +part in any of them): + +- **Cancel flickered under the pointer.** Every progress tick rebuilt the + card's buttons, so the Cancel being hovered was swapped for a fresh one many + times a second. The buttons are now only rebuilt when they change. +- **The Last frame box stayed empty.** A file's container can run a moment past + its video's last frame when the audio is longer (`visualiser.mp4` here: the + container ends at 3:10.334, the picture at 3:10.284). A seek into that gap + decodes nothing. The preview now steps back until it finds a picture. +- **Advanced Options opened, and "applied", on a card being converted.** + Nothing changed (the settings were sent when Convert was pressed), but the + dialog said it had. A card in the batch, queued or running, now locks its + options button, its image-count line and its format. + +> **Status: not yet run.** + +Use a video long enough to convert for ten seconds or more (a few minutes of +1080p to WebM is plenty), plus a second file to queue behind it. + +### Cancel + +- [ ] Convert the long video. While it runs, rest the pointer on its + **Cancel** button without moving it: the hover colour holds steady, with + no flicker, in both light and dark themes. +- [ ] Click it: the job cancels and the card shows Cancelled, with no Cancel + button left behind. + +### Last frame + +- [ ] Add `visualiser.mp4` (or any video whose audio runs past its picture), + choose WebM, open **Advanced Options**. Under Trim, **Last frame** shows + a picture, not an empty grey box. +- [ ] Drag the end handle back a few seconds and then right to the end: the + picture updates each time and is never left blank. +- [ ] A normal video still shows the right first and last frames. + +### Locked while converting + +- [ ] Add two files, set both to a format, select neither, and press + **Convert**. While the first runs, both cards' **tune** icons are dimmed + and do nothing when clicked, and both format selects are disabled. The + second is still queued, so its are too. +- [ ] On a frames conversion (a video to PNG), the orange "about N images" + line is not clickable while the job is in the batch. +- [ ] When the batch finishes, the tune icon, the image-count line and the + format select all work again. Advanced Options opens, and Apply says + **Settings applied to 1 file.** +- [ ] Bulk Edit still works on a selection when nothing is converting. diff --git a/package-lock.json b/package-lock.json index 247bc57..a503b95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "diamond-file-converter", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diamond-file-converter", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "license": "Apache-2.0", "dependencies": { "@diamonddigitaldev/electron-kit": "0.1.0-beta.3", diff --git a/package.json b/package.json index 41c8eb9..a52dea7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "diamond-file-converter", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "description": "An Electron-based desktop app for converting audio, video, and image files.", "main": "src/main.js", "funding": { diff --git a/src/main.js b/src/main.js index 79d9a16..af12b65 100644 --- a/src/main.js +++ b/src/main.js @@ -17,6 +17,8 @@ const { menuItems } = require("./menu"); // Frame previews are decoration beside a slider, not content. const PREVIEW_FRAME_HEIGHT = 144; const PREVIEW_FRAME_TIMEOUT_MS = 5000; +// How far back to step, in seconds, when a seek lands after the last picture. +const PREVIEW_FRAME_BACKOFF = [0.25, 1, 5]; const FILE_DIALOG_FILTERS = [ { name: "Supported Files", extensions: formats.SUPPORTED_EXTENSIONS }, @@ -453,11 +455,29 @@ kit.ipc.handle(IPC.SHELL_SHOW_IN_FOLDER, (_event, target) => { shell.showItemInF * to null and the dialog shows nothing. A missing preview must never be able to * block choosing a frame. */ -kit.ipc.handle(IPC.PREVIEW_FRAME, (_event, request) => { +kit.ipc.handle(IPC.PREVIEW_FRAME, async (_event, request) => { const inputPath = request && request.inputPath; const timestamp = Number(request && request.timestamp) || 0; if (!inputPath) return null; + // The container's duration is often a little past the video's last frame, + // because the audio runs on longer. A seek into that gap decodes nothing and + // exits cleanly, which left the end preview blank; the frame worth showing + // there is the last one, a moment earlier. + let result = await grabFrame(inputPath, timestamp); + for (const back of PREVIEW_FRAME_BACKOFF) { + if (result !== "empty" || timestamp <= 0) break; + result = await grabFrame(inputPath, Math.max(0, timestamp - back)); + } + return result === "empty" ? null : result; +}); + +/** + * One frame at `timestamp` as a JPEG data URL. "empty" when ffmpeg ran cleanly + * and found no picture there, which is worth stepping back from; null when it + * failed, which is not. + */ +function grabFrame(inputPath, timestamp) { return new Promise((resolve) => { const child = spawn(getFfmpegPath(), [ "-hide_banner", "-loglevel", "error", @@ -486,11 +506,12 @@ kit.ipc.handle(IPC.PREVIEW_FRAME, (_event, request) => { child.stdout.on("data", (chunk) => chunks.push(chunk)); child.on("error", () => finish(null)); child.on("close", (code) => { - if (code !== 0 || chunks.length === 0) return finish(null); + if (code !== 0) return finish(null); + if (chunks.length === 0) return finish("empty"); finish(`data:image/jpeg;base64,${Buffer.concat(chunks).toString("base64")}`); }); }); -}); +} kit.ipc.handle(IPC.APP_GET_FORMATS, () => ({ conversionMap: formats.buildLegacyConversionMap(), diff --git a/src/renderer.js b/src/renderer.js index c11e6e7..8461135 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -20,6 +20,14 @@ let jobs = []; let selection = new Set(); let selectionAnchor = null; let converting = false; +// Jobs handed to main and not yet back. Their format and settings were sent +// when Convert was pressed, so nothing edited on them now would take effect. +let inFlight = new Set(); + +/** Is this job part of the conversion that is running, queued or not? */ +function isLocked(job) { + return inFlight.has(job.id); +} // 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. @@ -324,8 +332,8 @@ function setTarget(ids, targetExt) { for (const job of jobs) { if (!set.has(job.id)) continue; // The per-card select is disabled mid-run, but the bulk bar can still - // reach a running job. - if (job.status === "running") continue; + // reach a job in the batch. + if (isLocked(job)) continue; // Guard against a stale bulk option: never assign a format the source // cannot produce. Does not apply when clearing. if (next !== null && !canTarget(job, next)) continue; @@ -567,9 +575,13 @@ function paintCard(card, job) { progress.element.classList.toggle("d-none", !running); if (running) progress.set(typeof job.progress === "number" ? job.progress : null); - // The target select locks while the job is in flight. + // The format and the options lock while the job is in flight, queued as + // well as running: both were sent to main when Convert was pressed. + const locked = isLocked(job); const select = card.querySelector(".card-target"); - select.disabled = running; + select.disabled = locked; + card.querySelector(".card-configure").disabled = locked; + outputLine.disabled = locked; if (select.value !== (job.targetExt ?? "")) select.value = job.targetExt ?? ""; renderCardActions(card, job); @@ -577,6 +589,13 @@ function paintCard(card, job) { function renderCardActions(card, job) { const actions = card.querySelector(".card-actions"); + + // Repainted on every progress tick, so the buttons are only rebuilt when + // what they are changes. Rebuilding them each time swapped the Cancel + // button out from under the pointer, and its hover flickered as it ran. + const key = `${job.status}|${job.outputPath ?? ""}|${job.isDirectory === true}`; + if (actions.dataset.key === key) return; + actions.dataset.key = key; actions.innerHTML = ""; const buttons = []; @@ -1118,7 +1137,7 @@ function fillSelect(select, options, current, placeholder) { /** Open the dialog for a set of job ids. */ function openJobModal(ids) { - modalScope = ids.filter(id => jobs.some(j => j.id === id)); + modalScope = ids.filter(id => jobs.some(j => j.id === id && !isLocked(j))); if (modalScope.length === 0) return; const scoped = jobs.filter(j => modalScope.includes(j.id)); @@ -1441,19 +1460,23 @@ function applyJobModal() { // chosen format takes setTarget's early return and kept its settings. if (target) setTarget(modalScope, target); + let n = 0; for (const job of jobs) { if (!modalScope.includes(job.id)) continue; - if (job.status === "running") continue; + // Opening it on a job in flight is refused, but one can start + // converting while the dialog is open. + if (isLocked(job)) continue; // Each card gets its own copy. One shared object across a bulk edit // works only for as long as nothing ever edits a single card's settings // in place, which is not a property worth relying on. job.settings = structuredClone(settings); updateCard(job); + n++; } jobModal.hide(); - const n = modalScope.length; + if (n === 0) return; kit.ui.toast(`Settings applied to ${kit.format.countOf(n, "file")}.`, { type: "success" }); } @@ -1484,6 +1507,7 @@ async function startConversion() { } converting = true; + inFlight = new Set(runnable.map(j => j.id)); for (const job of runnable) { job.status = "pending"; job.progress = 0; @@ -1508,6 +1532,7 @@ async function startConversion() { } function applyResult(job, result) { + inFlight.delete(job.id); job.status = result.status; job.error = result.error ?? null; job.outputPath = result.outputPath ?? null; diff --git a/src/styles.css b/src/styles.css index 66af845..6e3ddbf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -458,7 +458,9 @@ a { transition: color var(--dur-micro) var(--ease-micro); } -.card-configure:hover { color: var(--accent-text); } +.card-configure:hover:not(:disabled) { color: var(--accent-text); } +/* Locked while its conversion is in flight. */ +.card-configure:disabled { opacity: 0.4; cursor: default; } .card-configure .material-icons-round { font-size: 16px; line-height: 1; display: block; } .card-settings { @@ -760,10 +762,12 @@ a { cursor: pointer; } -.card-output:hover { +.card-output:hover:not(:disabled) { text-decoration: underline; } +.card-output:disabled { cursor: default; } + .card-output:focus-visible { outline: none; box-shadow: 0 0 0 0.2rem rgba(var(--accent-rgb), 0.35);