Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion docs/MANUAL-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
27 changes: 24 additions & 3 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand Down
39 changes: 32 additions & 7 deletions src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -567,16 +575,27 @@ 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);
}

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 = [];
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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" });
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading