From 99c4a318a458e0ea6b8d40cc12e2aa8373551bc1 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Tue, 6 Oct 2026 23:56:33 +0100 Subject: [PATCH 1/8] Restart Now's busy question is cleared however a conversion or a join ends converting and joinRunning were cleared only when the run resolved; a run that threw left Restart Now asking about a conversion that wasn't running. --- src/renderer.js | 36 +++++++++++++++++++++++------------- test/kit.test.js | 7 +++++++ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/renderer.js b/src/renderer.js index 7350ea8..fa0f228 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1518,16 +1518,21 @@ async function startConversion() { // 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, ...(job.settings ?? {}), - output: withMirrorRoot(job) }); - applyResult(job, result); - return result; - })); - - converting = false; - render(); + // However it ends: a batch that threw would otherwise leave Restart Now + // asking about a conversion that isn't running. + let results; + try { + results = await Promise.all(runnable.map(async (job) => { + const result = await api.runJob( + { id: job.id, inputPath: job.filePath, targetExt: job.targetExt, ...(job.settings ?? {}), + output: withMirrorRoot(job) }); + applyResult(job, result); + return result; + })); + } finally { + converting = false; + render(); + } announce(results); } @@ -1889,9 +1894,14 @@ async function runJoin() { joinProgress.set(0); renderJoinActions(); - const result = await api.runJoin(spec); - joinRunning = null; - renderJoinActions(); + // However it ends, as a conversion's. + let result; + try { + result = await api.runJoin(spec); + } finally { + joinRunning = null; + renderJoinActions(); + } if (result.status === "done") { kit.ui.toast(`Joined ${kit.format.countOf(joinClips.length, "file")} into ${p.basename(result.outputPath)}.`, { type: "success" }); diff --git a/test/kit.test.js b/test/kit.test.js index b35e241..9071723 100644 --- a/test/kit.test.js +++ b/test/kit.test.js @@ -151,6 +151,13 @@ test("the Title Case slips DESIGN.md lists are fixed: buttons and titles are Tit } }); +test("what Restart Now asks about is cleared however a conversion or a join ends, a throw included", () => { + const renderer = read("renderer.js"); + assert.match(renderer, /busy: \(\) => \(converting \? "A conversion is running\." : joinRunning \? "A join is running\." : null\)/); + assert.match(renderer, /\} finally \{\r?\n\s+converting = false;/); + assert.match(renderer, /\} finally \{\r?\n\s+joinRunning = null;/); +}); + test("progress of null is \"not known\": a card's and Join's bars slide, never sit at 0% or read null%", () => { const renderer = read("renderer.js"); assert.match(renderer, /progress\.set\(typeof job\.progress === "number" \? job\.progress : null\)/); From 0096351bcf6eca05ddf0cbfebbdf14cf153b20f7 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Wed, 7 Oct 2026 02:42:08 +0100 Subject: [PATCH 2/8] Manual testing: 2.0.0-beta.6 passed --- docs/MANUAL-TESTING.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index de3e18c..f75bdfa 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -2724,42 +2724,46 @@ the Windows installer is the kit's asking installer and `installer.nsh` is gone. It also passes its `appId` to the kit, says when it's busy, and takes the kit's Update tab changes since 0.1.0-beta.3. -> **Status: not yet run.** +> **Status: passed 2026-10-07.** Will ran the installer and checked the upgrade +> from an install for everyone and the taskbar pin on his PC; the rest was +> signed off by a code review against electron-kit's CI install check. Two +> findings: the right-click box's text was clipped (electron-kit #50, in +> beta.7), and Restart Now could keep asking after a run that threw (#9). ### Upgrading an install for everyone (every earlier version's) -- [ ] With beta.5 installed (for everyone), run the beta.6 installer. It does +- [x] With beta.5 installed (for everyone), run the beta.6 installer. It does not ask who it's for: it upgrades that copy, and Settings > Apps lists Diamond File Converter once. -- [ ] Right-click a file, then a folder: **Convert with Diamond File +- [x] Right-click a file, then a folder: **Convert with Diamond File Converter** is there once on each (Windows 11: under "Show more options"), with the app's icon, and opens the file or folder in the app. -- [ ] Right-click an `.mp3`, Open with: Diamond File Converter is offered. +- [x] Right-click an `.mp3`, Open with: Diamond File Converter is offered. ### A fresh install, only for me -- [ ] Uninstall, then run the installer again: it asks who it's for, "Only for +- [x] Uninstall, then run the installer again: it asks who it's for, "Only for me" chosen, and installs with no administrator prompt. -- [ ] The file types page shows the 22 types in three groups, all ticked, and +- [x] The file types page shows the 22 types in three groups, all ticked, and the right-click box ticked. Untick **WMA** and the right-click box. -- [ ] After the install, a `.wma` doesn't offer the app in Open with, an +- [x] After the install, a `.wma` doesn't offer the app in Open with, an `.mp3` does, and there's no Convert entry on right-click. -- [ ] Install the same build again over it: WMA and the right-click box start +- [x] Install the same build again over it: WMA and the right-click box start unticked. -- [ ] Uninstall: the app is gone from Open with for every type. +- [x] Uninstall: the app is gone from Open with for every type. ### The Update tab and Restart Now -- [ ] Open the app and go straight to Settings > Update: the status line is +- [x] Open the app and go straight to Settings > Update: the status line is never empty. It says "Updates haven't been checked yet." for a moment at most, then "Checking for updates…", then the result. -- [ ] Open Settings > Update only after ten seconds: one check has run, and +- [x] Open Settings > Update only after ten seconds: one check has run, and Check for Updates runs another. -- [ ] With an update downloaded, start a long conversion and press **Restart +- [x] With an update downloaded, start a long conversion and press **Restart Now**: it asks first ("A conversion is running."), and Cancel keeps the conversion going. The same while a join runs ("A join is running."). ### Notifications and the taskbar (Windows) -- [ ] Pin the app to the taskbar and open it: its window groups under the +- [x] Pin the app to the taskbar and open it: its window groups under the pinned icon, not a second one. From dc977ec9ea91c262a00f0278a20d479996b1401d Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Wed, 7 Oct 2026 02:42:17 +0100 Subject: [PATCH 3/8] Manual testing: say how beta.6 was signed off --- docs/MANUAL-TESTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index f75bdfa..ce2aa46 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -2724,9 +2724,9 @@ the Windows installer is the kit's asking installer and `installer.nsh` is gone. It also passes its `appId` to the kit, says when it's busy, and takes the kit's Update tab changes since 0.1.0-beta.3. -> **Status: passed 2026-10-07.** Will ran the installer and checked the upgrade -> from an install for everyone and the taskbar pin on his PC; the rest was -> signed off by a code review against electron-kit's CI install check. Two +> **Status: signed off by Will 2026-10-07** on a code review against +> electron-kit's CI install check, after he'd run the installer itself. The +> upgrade's administrator prompt and the taskbar pin weren't run by hand. Two > findings: the right-click box's text was clipped (electron-kit #50, in > beta.7), and Restart Now could keep asking after a run that threw (#9). From ef1dde52aa325ab87c258a45fbb536bc4644db76 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Thu, 15 Oct 2026 19:16:39 +0100 Subject: [PATCH 4/8] A converted card locks until Convert Again, which runs it again Once a card had converted, its format and Advanced Options could still be changed, but nothing would run them: Convert skips a Done card, and only a failed card offered Retry. A converted card now keeps the format and options that made its file, locked as a card in the run is, with Convert Again beside Show in Folder. Convert Again sends it back to Ready with the same format and options, unlocked, and Convert runs only it. The earlier file is left where it is: a run onto the same name meets File Already Exists, as any other does. The lock, the reset and the card's buttons move into display.js, where the suite can reach them. The bulk bar's format and Bulk Edit are disabled when every card they would apply to is locked, rather than taking a pick and changing nothing. --- docs/MANUAL-TESTING.md | 38 ++++++++++++++++++++ src/core/display.js | 49 ++++++++++++++++++++++++++ src/renderer.js | 80 +++++++++++++++++++++++------------------- test/display.test.js | 64 ++++++++++++++++++++++++++++++++- 4 files changed, 194 insertions(+), 37 deletions(-) diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index ce2aa46..717b412 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -2767,3 +2767,41 @@ Update tab changes since 0.1.0-beta.3. - [x] Pin the app to the taskbar and open it: its window groups under the pinned icon, not a second one. + +## Convert Again: a converted card can be converted again (2026-10-15) + +Will found it on beta.6: once a card had converted, its format and Advanced +Options could still be changed, but nothing would run them. Convert skips a +card that's Done, and only a failed card had Retry. Now a converted card keeps +the format and options that made its file, locked as a card being converted +is, and **Convert Again** under it unlocks it at Ready. Then Convert runs it +(and only it) again. This replaces beta.5's "when the batch finishes … the +format select all work again" for a card that converted; a card that failed or +was cancelled still unlocks when the batch ends. + +> **Status: not yet run by hand.** Driven once in the running app on +> 2026-10-15 (lock, Convert Again, the prompt, Save as New), not on an +> installed build. + +### Locked once converted + +- [ ] Convert a file. When it's Done, its format select and its **tune** icon + are disabled, and so is the orange image-count line on a frames + conversion. **Remove** still works. +- [ ] Under it: **Show in Folder** (or **Open Folder** for frames) and + **Convert Again**. +- [ ] With only converted cards, **Convert all to** in the bar is disabled; select + one and **Bulk Edit** is disabled too. Add a new file and both work again, + and change only the new card. + +### Convert Again + +- [ ] Click **Convert Again**: the card reads Ready with the same format and + settings line, its buttons go, its format select and tune icon work, and + the keyboard focus is on its format select. +- [ ] Change a setting in Advanced Options and press **Convert**: only this + card runs. As its output name is taken, **File Already Exists** asks. +- [ ] **Save as New** writes `name (1).ext` beside the first file, which is + untouched. Do it again on the same card with **Overwrite**: the first + file is replaced. **Skip This File** leaves the card Skipped and writes + nothing. diff --git a/src/core/display.js b/src/core/display.js index 7319f04..92e2f79 100644 --- a/src/core/display.js +++ b/src/core/display.js @@ -200,6 +200,52 @@ } } + /** + * Whether a card's format and options are fixed. A card in the conversion + * that is running is, queued as well as converting: both were sent to main + * when Convert was pressed. So is a card that has converted: it keeps the + * format and options that made its file, as Dropgate's finished upload + * cards do, until Convert Again reopens it. Left unlocked, its options + * could be changed under a "Done" that no longer described them, and + * nothing would ever run them. + */ + function isLocked(job, inFlight) { + return inFlight.has(job.id) || job.status === STATUS.DONE; + } + + /** + * Send a card back to Ready, keeping its format and options. What it + * wrote before is left on disk: a run onto the same name meets the + * "File Already Exists" prompt like any other. + */ + function reopen(job) { + job.status = STATUS.PENDING; + job.error = null; + job.progress = 0; + job.outputPath = null; + job.isDirectory = false; + job.fileCount = 0; + return job; + } + + /** The buttons under a card, in order: an id the renderer wires, and the label. */ + function cardActions(job) { + const button = (id, label, variant = "btn-outline-secondary") => ({ id, label, variant }); + switch (job.status) { + case STATUS.RUNNING: + return [button("cancel", "Cancel", "btn-outline-danger")]; + case STATUS.DONE: + return [ + ...(job.outputPath ? [button("reveal", job.isDirectory ? "Open Folder" : "Show in Folder")] : []), + button("convertAgain", "Convert Again"), + ]; + case STATUS.ERROR: + return [button("retry", "Retry")]; + default: + return []; + } + } + /** "3 files converted, 1 failed, 2 skipped" — the post-run summary, in the kit's words. Skipped is told from cancelled. */ function summarise(jobs) { const tally = (status) => jobs.filter(j => j.status === status).length; @@ -781,6 +827,9 @@ checkTrimEntry, describeMeta, describeStatus, + isLocked, + reopen, + cardActions, summarise, overallProgress, iconForKind, diff --git a/src/renderer.js b/src/renderer.js index fa0f228..2ea791a 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -24,9 +24,9 @@ let converting = false; // 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? */ +/** Are this card's format and options fixed: in the run, or converted until Convert Again? */ function isLocked(job) { - return inFlight.has(job.id); + return display.isLocked(job, inFlight); } // Loaded as a plain script by index.html — the renderer has no Node access, @@ -331,8 +331,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 job in the batch. + // The per-card select is disabled on a locked card, but the bulk bar + // can still reach one in the batch or one that has converted. if (isLocked(job)) continue; // Guard against a stale bulk option: never assign a format the source // cannot produce. Does not apply when clearing. @@ -348,17 +348,10 @@ function setTarget(ids, targetExt) { job.targetExt = next; - // A job that already finished, failed or was cancelled becomes runnable - // again under a new target, so send it back to pending rather than - // leaving a stale "Done" or an old error sitting on the card. - if (job.status !== "pending") { - job.status = "pending"; - job.error = null; - job.progress = 0; - job.outputPath = null; - job.isDirectory = false; - job.fileCount = 0; - } + // A job that failed or was cancelled becomes runnable again under a + // new target, so send it back to pending rather than leaving an old + // error sitting on the card. (A finished one is locked, above.) + if (job.status !== "pending") display.reopen(job); updateCard(job); } @@ -576,7 +569,8 @@ function paintCard(card, job) { if (running) progress.set(typeof job.progress === "number" ? job.progress : null); // 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. + // well as running (both were sent to main when Convert was pressed), and + // once it has converted, until Convert Again (display.isLocked). const locked = isLocked(job); const select = card.querySelector(".card-target"); select.disabled = locked; @@ -598,24 +592,23 @@ function renderCardActions(card, job) { actions.dataset.key = key; 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; + const handlers = { + cancel: () => api.cancelJob(job.id), + reveal: () => (job.isDirectory ? api.openPath(job.outputPath) : api.showInFolder(job.outputPath)), + retry: () => { + display.reopen(job); updateCard(job); - })); - } + }, + // Unlocks the card at Ready, with the format and options it converted + // with, for Convert to run again. Its button goes with the Done, so + // the keyboard carries on from the format. + convertAgain: () => { + display.reopen(job); + updateCard(job); + card.querySelector(".card-target").focus(); + }, + }; + const buttons = display.cardActions(job).map(({ id, label, variant }) => makeAction(label, variant, handlers[id])); actions.classList.toggle("d-none", buttons.length === 0); for (const b of buttons) actions.appendChild(b); @@ -679,12 +672,26 @@ function renderSelection() { const shared = [...new Set(scope.map(j => j.targetExt))]; fillTargetSelect(select, targets, shared.length === 1 ? shared[0] : null); - select.disabled = targets.length === 0 || converting; + lockBulkControls(); $("bulk-target-note").textContent = targets.length === 0 ? `No format works for every ${everything ? "queued" : "selected"} file` : ""; } +/** + * The bulk bar's format and Bulk Edit are off while converting, and while + * every card they'd apply to is locked: setTarget and the dialog pass a locked + * card by, so a scope of converted cards would take a pick and change nothing. + * Called with the bar and with every card's repaint, as a card's lock changes. + */ +function lockBulkControls() { + const scope = bulkScope(); + const open = scope.some(j => !isLocked(j)); + const targets = display.commonTargets(scope.map(j => j.ext), TARGETS); + $("bulk-target").disabled = targets.length === 0 || converting || !open; + $("bulk-edit").disabled = converting || !open; +} + /** The bar under the grid (kit.ui.actionBar()): made once, in DOMContentLoaded. */ let actionBar = null; @@ -728,7 +735,7 @@ function renderActionBar() { canRun: jobs.some(j => j.targetExt && j.status !== "done"), canClear: !converting, }); - $("bulk-edit").disabled = converting; + lockBulkControls(); } // -- New Job dialog ---------------------------------------------------------- @@ -1487,7 +1494,8 @@ async function startConversion() { // with a format, so fixing one failed file and pressing Convert again // resubmitted the whole finished batch — and every one of those outputs // then collided with the file the previous run had just written. - // Re-running a finished card is what its own Retry button is for. + // Re-running a finished card is what its own Convert Again button is for: + // it sends that card back to Ready, and only that card runs again. const runnable = jobs.filter(j => j.targetExt && j.status !== "done"); if (runnable.length === 0) return; diff --git a/test/display.test.js b/test/display.test.js index 2dc039e..2d854f2 100644 --- a/test/display.test.js +++ b/test/display.test.js @@ -651,7 +651,69 @@ test("convert: an already-converted card is not resubmitted", () => { const filter = renderer.match(/const runnable = jobs\.filter\([^)]*\)/); assert.ok(filter, "expected startConversion to filter the job list"); assert.match(filter[0], /status !== "done"/, - "Convert must skip cards that already converted; Retry is what re-runs one"); + "Convert must skip cards that already converted; Convert Again is what re-runs one"); +}); + +// --------------------------------------------------------------------------- +// A converted card: locked until Convert Again (Will, 2026-10-15). Its options +// could be changed and nothing would ever run them: Convert skips a Done card, +// and Retry was only offered on a failed one. +// --------------------------------------------------------------------------- + +test("card: a converted card is locked, as a card in the run is, and nothing else is", () => { + const inFlight = new Set(["queued"]); + assert.equal(d.isLocked({ id: "queued", status: STATUS.PENDING }, inFlight), true); + assert.equal(d.isLocked({ id: "a", status: STATUS.DONE }, inFlight), true); + for (const status of [STATUS.PENDING, STATUS.ERROR, STATUS.CANCELLED, STATUS.SKIPPED]) { + assert.equal(d.isLocked({ id: "a", status }, inFlight), false, status); + } +}); + +test("card: a converted card offers Convert Again beside its folder, and only it", () => { + const labels = (job) => d.cardActions(job).map(a => a.label); + assert.deepEqual(labels({ status: STATUS.DONE, outputPath: "C:\\out\\clip.mp4" }), ["Show in Folder", "Convert Again"]); + assert.deepEqual(labels({ status: STATUS.DONE, outputPath: "C:\\out\\clip", isDirectory: true }), ["Open Folder", "Convert Again"]); + assert.deepEqual(labels({ status: STATUS.DONE, outputPath: null }), ["Convert Again"], + "a Done card without a path can still be converted again"); + assert.deepEqual(labels({ status: STATUS.ERROR }), ["Retry"]); + assert.deepEqual(labels({ status: STATUS.RUNNING }), ["Cancel"]); + for (const status of [STATUS.PENDING, STATUS.CANCELLED, STATUS.SKIPPED]) { + assert.deepEqual(labels({ status }), [], `${status}: Convert in the bar runs it`); + } + for (const job of [{ status: STATUS.DONE, outputPath: "x" }, { status: STATUS.ERROR }, { status: STATUS.RUNNING }]) { + for (const { label } of d.cardActions(job)) { + assert.match(label, /^[A-Z][a-z]*( ([A-Z][a-z]*|a|an|and|in|of|on|or|the|to))*$/, `"${label}" is in Title Case`); + } + } +}); + +test("card: Convert Again sends the card back to Ready with its format and options, unlocked", () => { + const settings = { video: { crf: 30 } }; + const job = { + id: "a", ext: "mov", targetExt: "mp4", settings, status: STATUS.DONE, progress: 100, + error: null, outputPath: "C:\\out\\clip", isDirectory: true, fileCount: 30, + }; + d.reopen(job); + assert.deepEqual(d.describeStatus(job), { text: "Ready", tone: "ready" }); + assert.equal(d.isLocked(job, new Set()), false, "its format and Advanced Options open again"); + assert.equal(job.targetExt, "mp4"); + assert.equal(job.settings, settings, "the options it converted with are kept, to change or run as they are"); + assert.deepEqual([job.progress, job.outputPath, job.isDirectory, job.fileCount], [0, null, false, 0]); + assert.deepEqual(d.cardActions(job), [], "its buttons go with the Done"); +}); + +test("card: the renderer takes the lock, the buttons and the reset from display.js", () => { + // The renderer can't be loaded here, so this holds it to the rules above: + // a Convert Again it wires, and no lock or reset of its own beside them. + const renderer = stripSource(fs.readFileSync(path.join(__dirname, "..", "src", "renderer.js"), "utf8")); + assert.match(renderer, /function isLocked\(job\) \{\s*return display\.isLocked\(job, inFlight\);\s*\}/); + assert.match(renderer, /display\.cardActions\(job\)\.map\(/); + for (const id of ["cancel", "reveal", "retry", "convertAgain"]) { + assert.match(renderer, new RegExp(`\\b${id}: \\(\\) =>`), `the card wires ${id}`); + } + assert.match(renderer, /convertAgain: \(\) => \{\s*display\.reopen\(job\);/); + assert.equal(renderer.match(/job\.status = "pending"/g)?.length ?? 0, 1, + "only startConversion sets a card pending itself; every reset is display.reopen()"); }); test("dialog: a codec pick is recorded before the controls are rebuilt", () => { From e12fb21424d46fe6642c1247beb9836051a82aef Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 16 Oct 2026 18:24:57 +0100 Subject: [PATCH 5/8] Overwrite writes beside the file and swaps it in, so a failed run can't delete it Converting a file to its own format onto itself sent FFmpeg straight at its own input, which it refuses ("cannot edit existing files in-place"). The clean-up after a failed run then deleted the output path, which was the original. Any overwrite whose run failed or was cancelled lost the old file the same way. A run onto something that already exists now writes name.dfc-partial.ext beside it and renames it over the target only once FFmpeg exits cleanly. --- src/core/paths.js | 13 ++++++ src/core/runner.js | 46 ++++++++++++++++--- test/runner.test.js | 105 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/src/core/paths.js b/src/core/paths.js index 1dcb609..c15920f 100644 --- a/src/core/paths.js +++ b/src/core/paths.js @@ -149,6 +149,18 @@ function resolveOutputPath(job, context = {}) { } } +/** + * Where to write an output that replaces something already on disk, until it + * has finished: "clip.dfc-partial.mp4" beside "clip.mp4". The extension stays + * last because ffmpeg picks the muxer from it. + */ +function getStagingPath(outputPath, isDirectory) { + if (isDirectory) return getUniqueDirPath(`${outputPath}.dfc-partial`); + const ext = path.extname(outputPath); + const stem = path.basename(outputPath, ext); + return getUniquePath(path.join(path.dirname(outputPath), `${stem}.dfc-partial${ext}`)); +} + /** Create the directory an output will be written into. */ function ensureParentDir(outputPath, isDirectory) { const dir = isDirectory ? outputPath : path.dirname(outputPath); @@ -163,5 +175,6 @@ module.exports = { sanitiseName, resolveOutputDir, resolveOutputPath, + getStagingPath, ensureParentDir, }; diff --git a/src/core/runner.js b/src/core/runner.js index e7d03cd..3debae7 100644 --- a/src/core/runner.js +++ b/src/core/runner.js @@ -211,8 +211,18 @@ class JobRunner extends EventEmitter { const { outputPath, isDirectory } = resolved; + // Replacing something that is already there — another file, or the + // input itself — goes through a staging path beside it, swapped in only + // once ffmpeg succeeds. Writing straight onto it meant a failed run's + // partial-output cleanup deleted the file being replaced, and ffmpeg + // always fails onto its own input ("cannot edit existing files + // in-place"), so Replace on a same-format card deleted the original. + const writePath = fs.existsSync(outputPath) + ? paths.getStagingPath(outputPath, isDirectory) + : outputPath; + try { - paths.ensureParentDir(outputPath, isDirectory); + paths.ensureParentDir(writePath, isDirectory); } catch (err) { this._finish(job, { status: STATUS.ERROR, error: `Could not create the output folder: ${err.message}` }); this._pump(); @@ -235,9 +245,9 @@ class JobRunner extends EventEmitter { listPath = path.join(tempDir, "list.txt"); fs.writeFileSync(listPath, buildConcatList(job.join.clips), "utf8"); } - args = buildJoinArgs(job.join, { outputPath, listPath }); + args = buildJoinArgs(job.join, { outputPath: writePath, listPath }); } else { - args = buildArgs(job, { outputPath }); + args = buildArgs(job, { outputPath: writePath }); } } catch (err) { this._cleanupTemp(tempDir); @@ -247,7 +257,7 @@ class JobRunner extends EventEmitter { } const child = this.spawn(this.ffmpegPath, args, { windowsHide: true }); - const entry = { job, child, outputPath, isDirectory, cancelled: false, stderr: "", tempDir }; + const entry = { job, child, outputPath, writePath, isDirectory, cancelled: false, stderr: "", tempDir }; this.active.set(job.id, entry); job.status = STATUS.RUNNING; @@ -275,7 +285,7 @@ class JobRunner extends EventEmitter { child.on("error", (err) => { this.active.delete(job.id); this._cleanupTemp(entry.tempDir); - this._cleanupPartial(outputPath, isDirectory); + this._cleanupPartial(writePath, isDirectory); this._finish(job, { status: STATUS.ERROR, error: `Could not start ffmpeg: ${err.message}` }); this._pump(); }); @@ -285,9 +295,19 @@ class JobRunner extends EventEmitter { this._cleanupTemp(entry.tempDir); if (entry.cancelled || this._cancelledAll) { - this._cleanupPartial(outputPath, isDirectory); + this._cleanupPartial(writePath, isDirectory); this._finish(job, { status: STATUS.CANCELLED }); } else if (code === 0) { + if (writePath !== outputPath) { + try { + swapIn(writePath, outputPath, isDirectory); + } catch (err) { + this._cleanupPartial(writePath, isDirectory); + this._finish(job, { status: STATUS.ERROR, error: `Could not replace the existing file: ${err.message}` }); + this._pump(); + return; + } + } this.emit("progress", { jobId: job.id, percent: 100 }); this._finish(job, { status: STATUS.DONE, @@ -296,7 +316,7 @@ class JobRunner extends EventEmitter { fileCount: isDirectory ? countFiles(outputPath) : 1, }); } else { - this._cleanupPartial(outputPath, isDirectory); + this._cleanupPartial(writePath, isDirectory); this._finish(job, { status: STATUS.ERROR, error: lastMeaningfulLine(entry.stderr) || `ffmpeg exited with code ${code}.`, @@ -445,6 +465,18 @@ function durationOf(job) { return full; } +/** + * Put a finished staged output where it belongs. A file renames over the old + * one in a single step; a frame folder can't, so the old folder goes first. + * A staged output that never appeared is an error, not a quiet success: the + * file it was meant to replace is still there, and the card must not say Done. + */ +function swapIn(stagedPath, outputPath, isDirectory) { + if (!fs.existsSync(stagedPath)) throw new Error("ffmpeg finished without writing anything."); + if (isDirectory) fs.rmSync(outputPath, { recursive: true, force: true }); + fs.renameSync(stagedPath, outputPath); +} + function countFiles(dirPath) { try { return fs.readdirSync(dirPath).length; diff --git a/test/runner.test.js b/test/runner.test.js index 3d6c64b..664207a 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -197,8 +197,11 @@ test("runner: a job skipped at the prompt frees its slot", async () => { const runner = new JobRunner({ ffmpegPath: "ffmpeg", concurrency: 1, - spawn: () => { + // "write" replaces the colliding file, so this ffmpeg has to write + // one, or the runner rightly reports a replace that produced nothing. + spawn: (_cmd, args) => { const child = fakeChild(); + fs.writeFileSync(args[args.length - 1], ""); setImmediate(() => child.emit("close", 0)); return child; }, @@ -376,3 +379,103 @@ test("runner: one Cancel All ends a batch whose files are still arriving", async cleanup(); } }); + +// --------------------------------------------------------------------------- +// Replacing a file that is already there +// --------------------------------------------------------------------------- + +/** + * A runner whose fake ffmpeg either writes `written` to the path it was given + * (its last argument) and exits 0, or writes a little and fails, as ffmpeg + * does on its own input ("cannot edit existing files in-place"). + */ +function replacingRunner({ fail, written = "new" }) { + const seen = []; + const runner = new JobRunner({ + ffmpegPath: "ffmpeg", + concurrency: 1, + spawn: (_cmd, args) => { + const target = args[args.length - 1]; + seen.push(target); + const child = fakeChild(); + setImmediate(() => { + if (fail) { + // ffmpeg refuses an in-place edit without opening the + // output, so only a different target is touched. + if (!fs.existsSync(target)) fs.writeFileSync(target, "partial"); + child.emit("close", 1); + } else { + fs.writeFileSync(target, written); + child.emit("close", 0); + } + }); + return child; + }, + }); + return { runner, seen }; +} + +async function runOne(runner, job) { + const done = new Promise(resolve => runner.once("idle", resolve)); + runner.enqueue(job); + runner.start(); + await done; +} + +test("runner: a failed Replace onto the input itself leaves the input in place", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dfc-runner-")); + try { + const input = path.join(dir, "clip.mp4"); + fs.writeFileSync(input, "original"); + const job = createJob({ inputPath: input, targetExt: "mp4", output: { onConflict: CONFLICT.OVERWRITE } }); + + const { runner, seen } = replacingRunner({ fail: true }); + await runOne(runner, job); + + assert.equal(job.status, STATUS.ERROR); + assert.notEqual(seen[0], input, "ffmpeg is never pointed at its own input"); + assert.equal(fs.readFileSync(input, "utf8"), "original", "the input survives the failure"); + assert.deepEqual(fs.readdirSync(dir), ["clip.mp4"], "no staging file is left behind"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runner: a successful Replace onto the input swaps the new file in", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dfc-runner-")); + try { + const input = path.join(dir, "clip.mp4"); + fs.writeFileSync(input, "original"); + const job = createJob({ inputPath: input, targetExt: "mp4", output: { onConflict: CONFLICT.OVERWRITE } }); + + const { runner } = replacingRunner({ fail: false, written: "converted" }); + await runOne(runner, job); + + assert.equal(job.status, STATUS.DONE); + assert.equal(job.outputPath, input); + assert.equal(fs.readFileSync(input, "utf8"), "converted"); + assert.deepEqual(fs.readdirSync(dir), ["clip.mp4"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runner: a failed Replace keeps the file it would have replaced", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dfc-runner-")); + try { + const input = path.join(dir, "clip.mov"); + const existing = path.join(dir, "clip.mp4"); + fs.writeFileSync(input, ""); + fs.writeFileSync(existing, "keep me"); + const job = createJob({ inputPath: input, targetExt: "mp4", output: { onConflict: CONFLICT.OVERWRITE } }); + + const { runner } = replacingRunner({ fail: true }); + await runOne(runner, job); + + assert.equal(job.status, STATUS.ERROR); + assert.equal(fs.readFileSync(existing, "utf8"), "keep me"); + assert.deepEqual(fs.readdirSync(dir).sort(), ["clip.mov", "clip.mp4"]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 3e25cc3e5fdd15a32f9d70e000cf5add47053f93 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 16 Oct 2026 18:24:58 +0100 Subject: [PATCH 6/8] Join trims each clip with the Advanced Options slider and its frames Each clip's Trim button opens the span control under its row: the slider, the exact-time boxes, the first and last frames, and Shift for one frame at a time. The drag and keys (wireTrimHandles) and the frame painter (framePreviewer) are now shared with Advanced Options rather than copied. --- src/renderer.js | 435 ++++++++++++++++++++++++++++++++++-------------- src/styles.css | 31 ++-- 2 files changed, 322 insertions(+), 144 deletions(-) diff --git a/src/renderer.js b/src/renderer.js index 2ea791a..a577893 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -780,69 +780,138 @@ const TRIM_FINE_PX = 4; // pixels of Shift-drag per fine step const FRAME_PREVIEW_DEBOUNCE = 220; // ms after the slider settles -let framePreviewTimer = null; -let framePreviewToken = 0; - /** * Where to actually decode a preview from. Seeking to exactly the duration * lands past the last frame and decodes nothing, which is precisely where the * end handle sits until someone drags it. */ -function previewAt(seconds) { - const limit = Math.max(0, trimState.duration - 0.05); +function previewAt(seconds, duration = trimState.duration) { + const limit = Math.max(0, duration - 0.05); return Math.max(0, Math.min(seconds, limit)); } /** - * Does the span being chosen have pictures to show? Audio has none, and nor - * does a song whose only "video" is its cover art. + * The frame at the last point kept, rather than at the end itself. The end of + * a span is where it stops, so the frame sitting exactly there is the first + * one left out; the last one kept is a frame earlier, and that is the one + * worth seeing when deciding where a clip ends. */ -function trimHasPictures() { - const meta = modalSample()?.meta; - return !!(meta && meta.ok && meta.hasVideo && !meta.isStill); -} - -/** How long one frame of the sample lasts, for stepping back off the end. */ -function sampleFrameLength() { - const fps = modalSample()?.meta?.video?.fps; - return fps > 0 ? 1 / fps : 0.04; +function lastKeptAt(start, end, meta) { + const fps = meta?.video?.fps; + return Math.max(start, end - (fps > 0 ? 1 / fps : 0.04)); } /** - * Show the frames at either end of the span. + * A painter for one set of frame previews: Advanced Options has one, and so + * does each clip opened for trimming in Join. * * Debounced and token-guarded: dragging a handle asks for a great many frames * in quick succession, and a slow decode that lands after the user has moved on * must not paint a frame they are no longer looking at. */ +function framePreviewer() { + let timer = null; + let token = 0; + return (filePath, duration, wanted) => { + clearTimeout(timer); + timer = setTimeout(async () => { + const mine = ++token; + for (const [img, seconds] of wanted) { + let data = null; + try { + data = await api.previewFrame({ inputPath: filePath, timestamp: previewAt(seconds, duration) }); + } catch (_) { + // A source that will not decode simply shows no preview. + } + if (mine !== token) return; + if (data) img.src = data; + else img.removeAttribute("src"); + } + }, FRAME_PREVIEW_DEBOUNCE); + }; +} + +const paintModalFrames = framePreviewer(); + +/** + * Drag and arrow-key handling for a pair of trim handles, shared by Advanced + * Options and Join so the two move alike. + * + * `span()` is the live { duration, start, end }, or null while there is + * nothing to trim. `step(fine)` is one step, coarse or fine, and + * `move(handle, seconds, fine)` places a handle and repaints. + */ +function wireTrimHandles(slider, thumbs, { span, step, move }) { + for (const [handle, thumb] of Object.entries(thumbs)) { + thumb.addEventListener("pointerdown", (e) => { + const at = span(); + if (!at) return; + e.preventDefault(); + thumb.setPointerCapture(e.pointerId); + // Tracked unsnapped and moved by each pointer event's own + // distance, not the distance from where the drag began. Shift can + // go down or up mid-drag, and measuring from the origin at the new + // rate would throw the handle to somewhere else entirely. + let lastX = e.clientX; + let raw = at[handle]; + + const onMove = (ev) => { + const { duration } = span() ?? at; + const width = slider.getBoundingClientRect().width || 1; + const perPixel = duration / width; + // Shift slows the drag to a few pixels per frame, however long + // the clip, so single frames can actually be reached by hand. + const rate = ev.shiftKey + ? Math.min(perPixel, step(true) / TRIM_FINE_PX) + : perPixel; + slider.classList.toggle("fine", ev.shiftKey); + raw = Math.max(0, Math.min(duration, raw + (ev.clientX - lastX) * rate)); + lastX = ev.clientX; + move(handle, raw, ev.shiftKey); + }; + 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) => { + const at = span(); + if (!at) return; + const fine = e.shiftKey; + const current = at[handle]; + if (e.key === "ArrowLeft") { e.preventDefault(); move(handle, current - step(fine), fine); } + else if (e.key === "ArrowRight") { e.preventDefault(); move(handle, current + step(fine), fine); } + else if (e.key === "Home") { e.preventDefault(); move(handle, 0); } + else if (e.key === "End") { e.preventDefault(); move(handle, at.duration); } + }); + } +} + +/** + * Does the span being chosen have pictures to show? Audio has none, and nor + * does a song whose only "video" is its cover art. + */ +function trimHasPictures() { + const meta = modalSample()?.meta; + return !!(meta && meta.ok && meta.hasVideo && !meta.isStill); +} + +/** Show the frames at either end of the span. */ function requestFramePreviews() { const sample = modalSample(); const visible = !$("jm-frame-previews").classList.contains("d-none"); if (!visible || !sample || !trimState.available) return; - // The end of a span is where it stops, so the frame sitting exactly there - // is the first one left out. The last one kept is a frame earlier, and that - // is the one worth seeing when deciding where a clip ends. - const lastKept = Math.max(trimState.start, trimState.end - sampleFrameLength()); + const lastKept = lastKeptAt(trimState.start, trimState.end, sample.meta); const wanted = isSingleFrame() - ? [["jm-frame-start", trimState.start]] - : [["jm-frame-start", trimState.start], ["jm-frame-end", lastKept]]; - - clearTimeout(framePreviewTimer); - framePreviewTimer = setTimeout(async () => { - const token = ++framePreviewToken; - for (const [id, seconds] of wanted) { - let data = null; - try { - data = await api.previewFrame({ inputPath: sample.filePath, timestamp: previewAt(seconds) }); - } catch (_) { - // A source that will not decode simply shows no preview. - } - if (token !== framePreviewToken) return; - if (data) $(id).src = data; - else $(id).removeAttribute("src"); - } - }, FRAME_PREVIEW_DEBOUNCE); + ? [[$("jm-frame-start"), trimState.start]] + : [[$("jm-frame-start"), trimState.start], [$("jm-frame-end"), lastKept]]; + paintModalFrames(sample.filePath, trimState.duration, wanted); } /** @@ -1069,54 +1138,11 @@ function initTrimSlider() { }); } - 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); - // Tracked unsnapped and moved by each pointer event's own - // distance, not the distance from where the drag began. Shift can - // go down or up mid-drag, and measuring from the origin at the new - // rate would throw the handle to somewhere else entirely. - let lastX = e.clientX; - let raw = trimState[handle]; - - const onMove = (ev) => { - const width = slider.getBoundingClientRect().width || 1; - const perPixel = trimState.duration / width; - // Shift slows the drag to a few pixels per frame, however long - // the clip, so single frames can actually be reached by hand. - const rate = ev.shiftKey - ? Math.min(perPixel, currentTrimStep(true) / TRIM_FINE_PX) - : perPixel; - slider.classList.toggle("fine", ev.shiftKey); - raw = Math.max(0, Math.min(trimState.duration, raw + (ev.clientX - lastX) * rate)); - lastX = ev.clientX; - moveTrimHandle(handle, raw, ev.shiftKey); - }; - 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 fine = e.shiftKey; - const step = currentTrimStep(fine); - const current = trimState[handle]; - if (e.key === "ArrowLeft") { e.preventDefault(); moveTrimHandle(handle, current - step, fine); } - else if (e.key === "ArrowRight") { e.preventDefault(); moveTrimHandle(handle, current + step, fine); } - else if (e.key === "Home") { e.preventDefault(); moveTrimHandle(handle, 0); } - else if (e.key === "End") { e.preventDefault(); moveTrimHandle(handle, trimState.duration); } - }); - } + wireTrimHandles(slider, { start: $("jm-trim-thumb-start"), end: $("jm-trim-thumb-end") }, { + span: () => (trimState.available ? trimState : null), + step: currentTrimStep, + move: moveTrimHandle, + }); } function fillSelect(select, options, current, placeholder) { @@ -1608,15 +1634,7 @@ function onViewChange(name) { let joinClips = []; // { id, filePath, meta, trim: { start, end } } let joinRunning = null; // the id of the job in flight - -/** - * "1:23" or "83" both mean 83 seconds. Blank means "no limit at this end", and - * so, as it always has here, does anything that is not a time. - */ -function parseTime(text) { - const value = display.parseTimecode(text); - return Number.isNaN(value) ? null : value; -} +let joinTrimOpen = null; // the id of the clip whose trim is open, if any /** * Join's counterpart to ingestPaths: expand folders in the main process, then @@ -1715,9 +1733,31 @@ function renderJoin() { renderJoinActions(); } +/** The line under a clip's name: its length, its size, and what it keeps. */ +function joinClipMetaText(clip) { + if (!clip.meta) return "Reading…"; + if (!clip.meta.ok) return "unreadable"; + const span = joinClipSpan(clip); + const bits = [kit.format.formatDuration(clip.meta.duration)]; + if (clip.meta.video) bits.push(`${clip.meta.video.width}×${clip.meta.video.height}`); + if (span != null && Math.abs(span - clip.meta.duration) > 0.05) { + bits.push(`keeping ${display.formatTimecode(span)}`); + } + // A length that can't be known leaves no empty part before the first dot. + return bits.filter(Boolean).join(" · "); +} + +/** A clip's length, when it is known well enough to trim against. */ +function joinClipDuration(clip) { + const d = clip.meta?.ok ? clip.meta.duration : null; + return typeof d === "number" && d > 0 ? d : 0; +} + function buildJoinRow(clip, index) { - const row = document.createElement("li"); - row.className = "join-clip"; + const item = document.createElement("li"); + item.className = "join-clip"; + const row = document.createElement("div"); + row.className = "join-clip-row"; const badge = document.createElement("span"); badge.className = "join-index"; @@ -1731,27 +1771,33 @@ function buildJoinRow(clip, index) { name.title = clip.filePath; const meta = document.createElement("span"); meta.className = "join-clip-meta"; - if (!clip.meta) meta.textContent = "Reading…"; - else if (!clip.meta.ok) { meta.textContent = "unreadable"; meta.classList.add("is-bad"); } - else { - const span = joinClipSpan(clip); - const bits = [kit.format.formatDuration(clip.meta.duration)]; - if (clip.meta.video) bits.push(`${clip.meta.video.width}×${clip.meta.video.height}`); - if (span != null && Math.abs(span - clip.meta.duration) > 0.05) { - bits.push(`keeping ${kit.format.formatDuration(span)}`); - } - // A length that can't be known leaves no empty part before the first dot. - meta.textContent = bits.filter(Boolean).join(" · "); - } + meta.textContent = joinClipMetaText(clip); + meta.classList.toggle("is-bad", !!clip.meta && !clip.meta.ok); main.append(name, meta); - const trim = document.createElement("div"); - trim.className = "join-trim"; - trim.append( - timeInput(clip, "start", "from"), - Object.assign(document.createElement("span"), { className: "join-trim-sep", textContent: "→" }), - timeInput(clip, "end", "to"), + // Trimming opens under the row rather than living in it: the slider and + // its two frames need the row's whole width, and one clip at a time is all + // anyone trims. + const open = joinTrimOpen === clip.id; + const canTrim = joinClipDuration(clip) > 0; + const trimmed = clip.trim.start != null || clip.trim.end != null; + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = `btn btn-sm join-trim-toggle d-inline-flex align-items-center gap-1 ${open ? "btn-secondary" : "btn-outline-secondary"}`; + toggle.disabled = !canTrim; + toggle.title = canTrim ? "" : "Duration is not known for this file, so it cannot be trimmed."; + toggle.setAttribute("aria-expanded", String(open)); + toggle.append( + Object.assign(document.createElement("span"), { + className: "material-icons-round", textContent: "content_cut", + }), + document.createTextNode(trimmed ? "Trimmed" : "Trim"), ); + toggle.firstChild.setAttribute("aria-hidden", "true"); + toggle.addEventListener("click", () => { + joinTrimOpen = open ? null : clip.id; + renderJoin(); + }); const move = document.createElement("div"); move.className = "join-move"; @@ -1763,21 +1809,154 @@ function buildJoinRow(clip, index) { const remove = iconButton("close", "Remove", () => removeJoinClip(clip.id)); remove.className = "join-remove"; - row.append(badge, main, trim, move, remove); - return row; + row.append(badge, main, toggle, move, remove); + item.appendChild(row); + if (open && canTrim) item.appendChild(buildJoinTrim(clip, meta, toggle)); + return item; } -function timeInput(clip, which, placeholder) { - const input = document.createElement("input"); - input.type = "text"; - input.placeholder = placeholder; - input.spellcheck = false; - input.value = clip.trim[which] != null ? kit.format.formatDuration(clip.trim[which]) : ""; - input.addEventListener("change", () => { - clip.trim[which] = parseTime(input.value); - renderJoin(); +/** + * One clip's trim: the Advanced Options span control, its exact-time boxes and + * its two frames, for this clip alone. + * + * Painted in place as it moves rather than through renderJoin, which rebuilds + * the list and would take the handle out from under a drag. + */ +function buildJoinTrim(clip, metaLine, toggle) { + const duration = joinClipDuration(clip); + const span = { duration, start: clip.trim.start ?? 0, end: clip.trim.end ?? duration }; + const pictures = !!(clip.meta.hasVideo && !clip.meta.isStill); + const uid = `join-trim-${clip.id}`; + const el = (tag, className, props = {}) => + Object.assign(document.createElement(tag), className ? { className } : {}, props); + + const panel = el("div", "join-clip-trim"); + + const field = (which, label) => { + const wrap = el("div", `trim-field${which === "end" ? " trim-field-end" : ""}`); + const input = el("input", "form-control form-control-sm trim-input", { + id: `${uid}-${which}`, type: "text", inputMode: "decimal", spellcheck: false, autocomplete: "off", + }); + input.setAttribute("aria-describedby", `${uid}-error`); + wrap.append(el("label", "form-label", { htmlFor: input.id, textContent: label }), input); + return { wrap, input }; + }; + const startField = field("start", "Start"); + const endField = field("end", "End"); + const kept = el("span", "trim-kept text-body-secondary"); + const fields = el("div", "trim-fields"); + fields.append(startField.wrap, kept, endField.wrap); + + const error = el("div", "form-text text-danger d-none", { id: `${uid}-error`, role: "alert" }); + + const slider = el("div", "trim-slider"); + const range = el("div", "trim-range"); + const thumb = (handle, label) => { + const t = el("div", "trim-thumb", { tabIndex: 0 }); + t.dataset.handle = handle; + t.setAttribute("role", "slider"); + t.setAttribute("aria-label", label); + return t; + }; + const thumbs = { start: thumb("start", "Trim start"), end: thumb("end", "Trim end") }; + slider.append(el("div", "trim-track"), range, thumbs.start, thumbs.end); + + const frame = (caption) => { + const figure = el("figure", "frame-preview"); + const img = el("img", "", { alt: "" }); + figure.append(img, el("figcaption", "", { textContent: caption })); + return { figure, img }; + }; + const first = frame("First frame"); + const last = frame("Last frame"); + const previews = el("div", "frame-previews"); + previews.append(first.figure, last.figure); + + const help = el("div", "form-text", { + textContent: pictures + ? "Drag either end, or type an exact time. It moves in whole seconds; hold Shift to move one frame at a time." + : "Drag either end, or type an exact time. It moves in whole seconds; hold Shift to move a tenth of a second at a time.", + }); + + panel.append(fields, error, slider); + if (pictures) panel.appendChild(previews); + panel.appendChild(help); + + const paintFrames = framePreviewer(); + const errors = { start: null, end: null }; + const setError = (which, message) => { + errors[which] = message; + (which === "start" ? startField : endField).input.classList.toggle("is-invalid", !!message); + const shown = [errors.start, errors.end].filter(Boolean); + error.textContent = shown.join(" "); + error.classList.toggle("d-none", shown.length === 0); + }; + + const paint = () => { + const pct = (v) => `${(v / duration) * 100}%`; + thumbs.start.style.left = pct(span.start); + thumbs.end.style.left = pct(span.end); + range.style.left = pct(span.start); + range.style.width = pct(span.end - span.start); + startField.input.value = display.formatTimecode(span.start) ?? "0:00"; + endField.input.value = display.formatTimecode(span.end) ?? ""; + setError("start", null); + setError("end", null); + kept.textContent = `keeping ${display.formatTimecode(span.end - span.start) ?? "0:00"}`; + for (const handle of ["start", "end"]) { + const t = thumbs[handle]; + t.setAttribute("aria-valuemin", "0"); + t.setAttribute("aria-valuemax", String(Math.round(duration))); + t.setAttribute("aria-valuenow", String(Math.round(span[handle]))); + t.setAttribute("aria-valuetext", kit.format.formatDuration(span[handle]) ?? ""); + } + if (pictures) { + paintFrames(clip.filePath, duration, [ + [first.img, span.start], + [last.img, lastKeptAt(span.start, span.end, clip.meta)], + ]); + } + }; + + // The clip keeps only the ends that were moved, exactly as Advanced + // Options reads its slider back, so an untouched clip runs whole. + const commit = () => { + const round = (v) => Math.round(v * 1000) / 1000; + clip.trim.start = span.start > 0 ? round(span.start) : null; + clip.trim.end = span.end >= duration ? null : round(span.end); + paint(); + metaLine.textContent = joinClipMetaText(clip); + toggle.lastChild.textContent = clip.trim.start != null || clip.trim.end != null ? "Trimmed" : "Trim"; + renderJoinActions(); + }; + + const step = (fine) => display.trimStep(clip.meta, fine); + wireTrimHandles(slider, thumbs, { + span: () => span, + step, + move: (handle, seconds, fine = false) => { + span[handle] = display.placeTrimHandle(handle, seconds, { ...span, step: step(fine) }); + commit(); + }, }); - return input; + + for (const [which, { input }] of [["start", startField], ["end", endField]]) { + // Checked when the box is left or Enter is pressed, not on every + // keystroke — "1:" on the way to "1:23" is not a mistake yet. + const take = () => { + const result = display.checkTrimEntry(which, input.value, span); + if (result.error) { setError(which, result.error); return; } + span[which] = result.value; + commit(); + }; + input.addEventListener("change", take); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); take(); } + }); + } + + paint(); + return panel; } function iconButton(icon, title, onClick, disabled = false) { diff --git a/src/styles.css b/src/styles.css index 6e3ddbf..8c0b5e3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -586,15 +586,18 @@ a { .join-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; } .join-clip { - display: flex; - align-items: center; - gap: 0.75rem; padding: 0.6rem 0.75rem; border: 1px solid var(--bs-border-color); border-radius: 6px; background-color: var(--bs-body-bg); } +.join-clip-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + .join-index { flex-shrink: 0; width: 1.6rem; @@ -613,20 +616,16 @@ a { .join-clip-meta { font-size: 0.75rem; opacity: 0.7; } .join-clip-meta.is-bad { color: var(--bs-danger); opacity: 1; } -/* Two time fields rather than a slider. The trim slider in the settings dialog - is bound to that dialog's own element ids and its sample job; generalising it - for one more caller is a refactor, and duplicating it is worse. */ -.join-trim { display: flex; align-items: center; gap: 0.3rem; flex-shrink: 0; } -.join-trim input { - width: 5rem; - font-size: 0.8rem; - padding: 0.15rem 0.35rem; - border: 1px solid var(--bs-border-color); - border-radius: 4px; - background-color: var(--bs-body-bg); - color: inherit; +/* A clip's trim is the Advanced Options span control — the same slider, boxes + and frames, wired through the same wireTrimHandles() — opened under its row. */ +.join-trim-toggle { flex-shrink: 0; } +.join-trim-toggle .material-icons-round { font-size: 16px; } + +.join-clip-trim { + margin-top: 0.6rem; + padding-top: 0.6rem; + border-top: 1px solid var(--bs-border-color); } -.join-trim .join-trim-sep { opacity: 0.5; font-size: 0.8rem; } .join-move { display: flex; flex-direction: column; flex-shrink: 0; } .join-move button, From da862918e486f512fbd62c1a4c6d115899350c22 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 16 Oct 2026 18:24:58 +0100 Subject: [PATCH 7/8] Manual testing: overwrite onto itself, and Join's trim --- docs/MANUAL-TESTING.md | 66 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index 717b412..65341a3 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -2805,3 +2805,69 @@ was cancelled still unlocks when the batch ends. untouched. Do it again on the same card with **Overwrite**: the first file is replaced. **Skip This File** leaves the card Skipped and writes nothing. + +## Overwrite keeps the file when FFmpeg fails, and Join trims like Advanced Options (2026-10-16) + +Two things Will found on beta.6: + +- **A file converted onto itself disappeared.** Converting a file to its own + format (an `.mp4` to MP4, in its own folder) meets **File Already Exists** + with the file itself. **Overwrite** sent FFmpeg straight onto its own input, + which FFmpeg refuses ("FFmpeg cannot edit existing files in-place"). The + clean-up after any failed run then deleted the half-written output, and that + path was the original. The same clean-up deleted any file being overwritten + whenever a run onto it failed. Now a run that replaces something writes to + `name.dfc-partial.ext` beside it and swaps it in only when FFmpeg succeeds. + So converting a file onto itself works, and a failure leaves the old file as + it was. +- **Join's trim was two text boxes.** Each clip now has a **Trim** button that + opens the Advanced Options control under its row: the slider, the exact-time + boxes and the first and last frames, with Shift for one frame at a time. It + is one clip at a time, and the button reads **Trimmed** once a clip is + trimmed. The drag and the frames are the same code as Advanced Options now, + not a copy. + +> **Status: not yet run by hand.** Both driven in the running app on +> 2026-10-16 (a real FFmpeg in-place run and a failed overwrite; Join's drag, +> Shift-drag, Shift+arrow, frames, a refused time, an audio clip), not on an +> installed build. + +### Overwrite, onto the file itself + +- [ ] Copy a short `.mp4` somewhere you don't mind losing it. Add it, choose + **MP4**, press **Convert**. **File Already Exists** names the file + itself. Choose **Overwrite**: the card is Done, the file is still there + (its size has changed, as it was re-encoded), it plays, and there's no + `.dfc-partial` file beside it. +- [ ] The same with **Save as New**: `name (1).mp4` is written and the + original is untouched. + +### Overwrite, when FFmpeg fails + +- [ ] Make a broken file: copy any `.txt`, rename it `broken.mov`. Put a real + `broken.mp4` beside it. Add `broken.mov`, choose **MP4**, Convert, + **Overwrite**. The card fails with FFmpeg's reason, and `broken.mp4` is + still there and still plays. No `.dfc-partial` file is left. +- [ ] Start a long conversion with **Overwrite** onto an existing file and + **Cancel** it part way: the existing file is still there and still plays. + +### Join's trim + +- [ ] In Join, add two videos and an audio file. Each row has a **Trim** + button. Click one: the slider, Start and End, and the First and Last + frame pictures open under that row. Click another row's Trim: that one + opens and the first closes. +- [ ] Drag either end: it moves in whole seconds, and both pictures update + when you stop. Hold **Shift** while dragging: it moves one frame at a + time and the cursor is a crosshair. Shift+←/→ on a focused handle moves + one frame. The row's line reads "keeping …" and the button **Trimmed**. +- [ ] Type an end past the clip's length and press Enter: the box goes red + with "The end cannot be past the end of the clip, at …", and nothing + moves. Type a good time: it lands exactly as typed, and focus stays in + the box. +- [ ] The audio file's Trim has the slider and boxes but no pictures, and + Shift moves a tenth of a second. +- [ ] Join them: the joined file starts and ends where the trims said. +- [ ] Look at all of it in both light and dark themes. +- [ ] Advanced Options' trim on the Convert view still drags, Shift-drags and + shows its two frames as before. From 459a2d04d065b79d2d610492f396f36a5e4776d3 Mon Sep 17 00:00:00 2001 From: Will Knowles Date: Fri, 16 Oct 2026 18:26:42 +0100 Subject: [PATCH 8/8] chore: 2.0.0-beta.7; manual testing cleared as passed --- docs/MANUAL-TESTING.md | 241 +++++++++++++++++++++-------------------- package-lock.json | 4 +- package.json | 2 +- 3 files changed, 126 insertions(+), 121 deletions(-) diff --git a/docs/MANUAL-TESTING.md b/docs/MANUAL-TESTING.md index 65341a3..6cc3d57 100644 --- a/docs/MANUAL-TESTING.md +++ b/docs/MANUAL-TESTING.md @@ -16,6 +16,11 @@ sections — the history is useful. > section has been deleted rather than carried as dead weight — the feature it > covered was removed in `09eaca3` and no longer exists to test. > +> **2026-10-16, for 2.0.0-beta.7: Will cleared every box still open as +> passed**, across all sections, and they are ticked. The status lines below +> are kept as they were written, so where one says a box was left open or +> failed, it was cleared by that sign-off, not by a separate run. +> > A written checklist is not a passed one. Every section carries its own status > line; do not describe any build as tested on the strength of this file > existing. @@ -1071,12 +1076,12 @@ ordering. that specifically claims a single Cancel All press aborts everything. This box only covers the original complaint (the button being unreachable/inert), which is resolved. -- [ ] Not applicable — depends on the above. *(Original box text was already +- [x] Not applicable — depends on the above. *(Original box text was already lost before this session — see the 09-03 session note elsewhere in this file. The underlying blocker this depended on — the footer button being unreachable — is now gone, but without knowing what this item originally tested, it can't be verified here rather than guessed at.)* -- [ ] Not applicable — depends on the above. *(Same as above — original text +- [x] Not applicable — depends on the above. *(Same as above — original text unknown, not guessed at.)* ### The queue keeps moving after a prompt @@ -1113,7 +1118,7 @@ queue would stall — so this is the case that proves it does. - [x] Tick "Apply to all remaining" on the first prompt and choose Overwrite. One dialog fired (for `f3.webm`, the first in scan order that session), and all eight converted afterwards. -- [ ] Could not verify as specified — **Task Manager is on this environment's +- [x] Could not verify as specified — **Task Manager is on this environment's denylist for this session** and could not be opened to count `ffmpeg.exe` processes. As indirect, weaker evidence: the app's own per-card progress state never showed more than **two** cards reading @@ -1134,7 +1139,7 @@ queue would stall — so this is the case that proves it does. - [x] With no collision at all, no dialog ever appears. Held throughout this whole pass — the large majority of conversions run this session had no pre-existing output and none of them raised a prompt. -- [ ] Not independently tested this pass — per-card Cancel on a running +- [x] Not independently tested this pass — per-card Cancel on a running conversion wasn't specifically exercised against alpha.5. (It was confirmed working in the alpha.1 section, which this is a regression check against.) @@ -1216,7 +1221,7 @@ is actually junk (rename a `.txt` to `.mp4`). ### One message, not two -- [ ] Put a `.txt` **and** a renamed junk `.mp4` in a folder, and drop it. The +- [x] Put a `.txt` **and** a renamed junk `.mp4` in a folder, and drop it. The two are caught by different checks, but you get **one** toast listing both — not two toasts wording the same thing differently. Tried 2026-09-05 with a folder holding `note.txt` plus a `.txt` renamed to @@ -1244,7 +1249,7 @@ is actually junk (rename a `.txt` to `.mp4`). - [x] Nested subfolders are still walked. Verified by adding a folder whose only content one level down (in a subfolder) was a single video file — it was picked up and queued along with everything at the top level. -- [ ] An unreadable file still gets its own red "Could not read …" toast. +- [x] An unreadable file still gets its own red "Could not read …" toast. Could not verify as literally worded. What this environment could produce: a `.txt` renamed to `.mp4` (added to the queue, card marked "MP4 · unreadable"), and pressing Convert on it did produce a red @@ -1256,13 +1261,13 @@ is actually junk (rename a `.txt` to `.mp4`). drive from the sandbox's point of view, and `chmod` against it did not actually restrict the native Windows app's access. Leaving unticked rather than treating the conversion-failure toast as the same thing. -- [ ] Drag and drop, **Add Files**, **Add Folder** and **Menu → Open Folder** +- [x] Drag and drop, **Add Files**, **Add Folder** and **Menu → Open Folder** all behave the same as each other. Verified for three of the four: **Add Files**, **Add Folder**, and **Menu → Open Folder** all produced identical toasts on the same test folders/files this pass. Drag and drop not attempted — File Explorer access this session is click-only (no drag-drop), so it could not be driven from here. -- [ ] Check the toast in **both themes** — the list's inset background and the +- [x] Check the toast in **both themes** — the list's inset background and the underlined action button must be readable on all four toast colours (info, success, warning, danger). Not attempted this pass — the app appears to follow the OS theme with no in-app toggle, and switching the @@ -1346,7 +1351,7 @@ two or three `.txt` files. file in the folder and then explain why, which is honest and useless.)* Confirmed: with Audio and Video off and only Images ticked, clicking Images did nothing — the checkbox stayed checked and blue. -- [ ] The options apply to a **dropped** folder too, not only the button — a +- [x] The options apply to a **dropped** folder too, not only the button — a drop can contain folders, and having the setting cover one route but not the other would be baffling. Could not verify the drag-and-drop route specifically — File Explorer access this session is click-only. Did @@ -1500,7 +1505,7 @@ bottom — e.g. `Source\2024\holiday\clip.mp4` and `Source\2023\misc\song.mp3`. Applied — settings persisted correctly this time (`mirrorRoot` defaulted to the file's own containing folder) and the output landed flat in the chosen destination, not alongside the source. -- [ ] *(Only if you have two drives.)* Not attempted — this machine's test +- [x] *(Only if you have two drives.)* Not attempted — this machine's test setup only used one drive for the pass. Per the checklist's own caveat, skipping this is expected when the hardware doesn't support it. @@ -1544,11 +1549,11 @@ rate (it will not). An MP3 or two for the audio-only case. - [x] Go back to Join: your clip list is still there too. - [x] Queue files in Files, switch to Join, add different files. The two lists are independent and neither disturbs the other. -- [ ] Not verified — drag-and-drop onto the window wasn't exercisable through +- [x] Not verified — drag-and-drop onto the window wasn't exercisable through this pass's input method (files were added via the Select Files dialog throughout). The list-independence and view-switching this depends on were confirmed, so this is a gap in coverage, not a known failure. -- [ ] Not verified, for the same reason (no way to check "inert while Join is +- [x] Not verified, for the same reason (no way to check "inert while Join is showing" without also being able to drive drag/drop and the keyboard shortcuts against the Files grid for comparison in the same pass). @@ -1664,7 +1669,7 @@ rate (it will not). An MP3 or two for the audio-only case. 2026-09-03, after a join that was interrupted at the output-exists prompt: `%TEMP%` held no `dfc-join-*` entry at all. The earlier pass could not reach that filesystem and left this open.)* -- [ ] Not tried this pass. +- [x] Not tried this pass. --- ## 2.0.0-alpha.5 — Advanced options actually stick @@ -1911,7 +1916,7 @@ danger are white on colour. confirms the headline fix: the prompt's buttons (**Cancel All · Skip This File · Overwrite · Save as New**) are now inside the dialog itself, not a footer button a window-modal dialog was blocking. -- [ ] **FAILED (partially).** With the prompt open, press **Cancel All**. The +- [x] **FAILED (partially).** With the prompt open, press **Cancel All**. The dialog closes and **every card** reads Cancelled — the one that was prompting and the five behind it. **Not what happened.** Reproduced twice: queued the six colliding files (`large` + `small1–5`), pressed @@ -1960,7 +1965,7 @@ danger are white on colour. > ### Not testable by an agent — the user tests this one > -> - [ ] Press **Escape** at the conflict prompt. It aborts the **whole run**, +> - [x] Press **Escape** at the conflict prompt. It aborts the **whole run**, > the same as Cancel All. This is intended. > > Computer-use cannot send the Escape key, so an automated pass cannot verify @@ -2039,13 +2044,13 @@ itself in the same circumstances. This is the case the previous pass was hitting. It needs enough files that they are still being read when you answer, so use a folder, not six files. -- [ ] Queue **thirty or more** colliding files — a folder ingest of a +- [x] Queue **thirty or more** colliding files — a folder ingest of a directory whose outputs already exist is the easiest way — and press **Convert**. -- [ ] Press **Cancel All** on the first prompt **as soon as it appears**, +- [x] Press **Cancel All** on the first prompt **as soon as it appears**, without waiting. Still exactly one dialog: every card ends Cancelled and no second prompt opens, however quickly you answered. -- [ ] Repeat, this time ticking **Apply to all remaining files** with **Skip +- [x] Repeat, this time ticking **Apply to all remaining files** with **Skip This File** instead. One dialog again, and every card reads Skipped — the choice is not forgotten half way through. @@ -2096,7 +2101,7 @@ are still being read when you answer, so use a folder, not six files. > ### Not testable by an agent — the user tests this one > -> - [ ] Press **Escape** at the conflict prompt instead of clicking Cancel +> - [x] Press **Escape** at the conflict prompt instead of clicking Cancel > All. One press aborts the **whole run**, exactly as Cancel All does. > This is intended. > @@ -2148,75 +2153,75 @@ named by one glyph, not two — in `docs/design-system/` (regenerate with ### Nothing moves when you switch -- [ ] Launch the app. The rail reads **Convert** and **Join**, and Convert is +- [x] Launch the app. The rail reads **Convert** and **Join**, and Convert is highlighted *immediately*, before anything is clicked. *(It used to light up only after a round trip through Join — the first launch showed no section at all.)* -- [ ] Click Join, then Convert, then Join again, watching the title **Diamond +- [x] Click Join, then Convert, then Join again, watching the title **Diamond File Converter**. It does not move by a pixel. *(It jumped up 7px on Join.)* -- [ ] Same switch, watching the drop zone's dashed border. It stays exactly +- [x] Same switch, watching the drop zone's dashed border. It stays exactly where it is — same size, same position — and only the icon and the label inside it change. -- [ ] Same switch, watching **Add Files** and **Add Folder** in the header. +- [x] Same switch, watching **Add Files** and **Add Folder** in the header. They are there on both sections, in the same place, at the same size, with the options caret beside Add Folder on both. -- [ ] Both themes. +- [x] Both themes. ### Nothing moves when you collapse -- [ ] With the rail expanded, press **Collapse**, watching the three icons — +- [x] With the rail expanded, press **Collapse**, watching the three icons — Convert, Join and the chevron. They stay on their lines while the rail narrows: the labels disappear, and nothing slides up or down. *(Each item used to shrink by 1.6px, so the icons below it crept upward.)* -- [ ] Press **Expand**. The same in reverse, and the chevron *turns* to face +- [x] Press **Expand**. The same in reverse, and the chevron *turns* to face the other way rather than flipping. -- [ ] Do it from Join as well. The collapse must not care which section is +- [x] Do it from Join as well. The collapse must not care which section is showing. -- [ ] Collapsed, hover each icon: the hover wash is the same rounded box as +- [x] Collapsed, hover each icon: the hover wash is the same rounded box as when expanded, just narrower. ### The two sections read as one app -- [ ] Convert's zone, top to bottom: a green ⇄ arrow, "Drag & Drop Files or +- [x] Convert's zone, top to bottom: a green ⇄ arrow, "Drag & Drop Files or Folders Here", a grey "or", **Select Files**. The ⇄ is the same glyph as the Convert item on the rail. -- [ ] Join's zone, top to bottom: the green two-into-one arrow, "Drag & Drop +- [x] Join's zone, top to bottom: the green two-into-one arrow, "Drag & Drop Files to Join Here", a grey "or", **Select Files**. The same four lines, the same colours on each, the same glyph as the Join item on the rail. *(It had five lines, and the extra one was in body colour, not grey.)* -- [ ] Click anywhere in Join's zone that is not the button: the file dialog +- [x] Click anywhere in Join's zone that is not the button: the file dialog opens. Click the button: it opens once, not twice. *(Convert's zone has always done this; Join's only answered to its button.)* -- [ ] Hover Join's zone: the same green wash as Convert's. Drag a file over it: +- [x] Hover Join's zone: the same green wash as Convert's. Drag a file over it: the same stronger wash. ### The toolbar adds to whichever section is showing -- [ ] On Join, **Add Files** → pick two clips. They are listed on Join, and +- [x] On Join, **Add Files** → pick two clips. They are listed on Join, and Convert's grid is untouched. -- [ ] On Join, **Add Folder** → a folder of clips. Every supported file in it +- [x] On Join, **Add Folder** → a folder of clips. Every supported file in it is listed, in name order, and the toast reads *Added N files* — with *skipped M* and a **Show them** link if the folder held anything the app cannot join. *(Join could not take a folder at all; a dropped one bounced with "None of those are files this can join.")* -- [ ] Drop a folder onto Join's zone. The same result as Add Folder. -- [ ] Untick **Include subfolders** in the options caret, then Add Folder a +- [x] Drop a folder onto Join's zone. The same result as Add Folder. +- [x] Untick **Include subfolders** in the options caret, then Add Folder a folder that has a subfolder of clips. The subfolder's clips are not listed. *(The folder options apply on Join exactly as on Convert.)* -- [ ] Switch to Convert, **Add Files**. They go to the grid, not to Join. -- [ ] Drop files on Convert while Join has a list. They go to the grid; the +- [x] Switch to Convert, **Add Files**. They go to the grid, not to Join. +- [x] Drop files on Convert while Join has a list. They go to the grid; the join list is untouched. ### Nothing else moved -- [ ] Queue files on Convert, pick formats, select two cards. Switch to Join +- [x] Queue files on Convert, pick formats, select two cards. Switch to Join and back: the grid, its selection and its chosen formats are exactly as left. -- [ ] Ctrl+A and Delete still act on the grid on Convert and do nothing on +- [x] Ctrl+A and Delete still act on the grid on Convert and do nothing on Join. -- [ ] Collapse the rail, quit, relaunch. Still collapsed. -- [ ] Add the same clip twice on Join, by two separate Add Files. It appears +- [x] Collapse the rail, quit, relaunch. Still collapsed. +- [x] Add the same clip twice on Join, by two separate Add Files. It appears twice. *(A folder walk lists each file once, but two adds are two adds.)* --- @@ -2263,86 +2268,86 @@ picture). ### Whole seconds by default -- [ ] Add the video, pick **MKV**, open Advanced options. The Trim section +- [x] Add the video, pick **MKV**, open Advanced options. The Trim section reads, top to bottom: **Start** and **End** boxes with *keeping …* between them, the slider, two frame previews (**First frame** under the start, **Last frame** under the end), then the help line. -- [ ] Drag the start handle slowly across. The Start box only ever shows whole +- [x] Drag the start handle slowly across. The Start box only ever shows whole seconds (`0:03`, `0:04`), never `0:03.47`. The command preview shows `-ss 3`, not `-ss 3.47`. -- [ ] Drag the end handle in from the right, then all the way back. You can +- [x] Drag the end handle in from the right, then all the way back. You can reach the true end of the clip. The End box shows its full length, for example `0:12.48`, and the command has **no** `-t`. -- [ ] Click a handle, then press ← and →. It moves a second at a time. +- [x] Click a handle, then press ← and →. It moves a second at a time. ### Shift for single frames -- [ ] Hold **Shift** and drag a handle. It moves a few pixels per frame, and +- [x] Hold **Shift** and drag a handle. It moves a few pixels per frame, and the box counts up in frames (`0:03.04`, `0:03.08` at 25fps). The cursor turns to a crosshair while Shift is held. -- [ ] Press and release Shift *during* a drag. The handle carries on from where +- [x] Press and release Shift *during* a drag. The handle carries on from where it is and does not jump somewhere else. -- [ ] Shift+← / Shift+→ on a focused handle. One frame per press. Watch the +- [x] Shift+← / Shift+→ on a focused handle. One frame per press. Watch the frame preview under that handle: it changes by exactly one frame. -- [ ] Repeat with the audio file (target WAV). A Shift-step is a tenth of a +- [x] Repeat with the audio file (target WAV). A Shift-step is a tenth of a second (`0:03.1`, `0:03.2`). The help line says "a tenth of a second". ### The handles never cover each other -- [ ] Set Start to `6` and End to `7`, then press Shift+← on the end handle +- [x] Set Start to `6` and End to `7`, then press Shift+← on the end handle until it stops. It stops **one frame** after the start. The two halves sit side by side as one pill, both still visible and grabbable. The command shows `-t 0.04` (at 25fps), not `-t 0`. -- [ ] Without Shift, press ← on the end handle from well to the right. It stops +- [x] Without Shift, press ← on the end handle from well to the right. It stops a whole second after the start. -- [ ] Try to drag the start handle past the end. It stops short and does not +- [x] Try to drag the start handle past the end. It stops short and does not cross. -- [ ] At both ends of the track, the handles are fully visible and not clipped +- [x] At both ends of the track, the handles are fully visible and not clipped by the edge of the dialog. ### Exact times -- [ ] Type `0:03.04` into Start and press **Enter**. The handle moves there, +- [x] Type `0:03.04` into Start and press **Enter**. The handle moves there, the command shows `-ss 3.04`, and the first-frame preview updates. The value is **not** rounded to a whole second. -- [ ] Each of these is accepted in the End box: `7.5`, `0:07.5`, `0:00:07.5`. -- [ ] Clear the End box and press Tab. It fills back in with the clip's full +- [x] Each of these is accepted in the End box: `7.5`, `0:07.5`, `0:00:07.5`. +- [x] Clear the End box and press Tab. It fills back in with the clip's full length, and the command has no `-t`. -- [ ] Type `0:02` into End while Start is `0:03.04`, then press Enter. The box +- [x] Type `0:02` into End while Start is `0:03.04`, then press Enter. The box goes red, and a red line under the boxes reads *The end must be after the start, at 0:03.04.* **Apply is disabled.** The slider has not moved. -- [ ] Fix the End box. The red clears and Apply comes back. -- [ ] Each of these is refused with a message: `abc`, `1:75`, a Start equal to +- [x] Fix the End box. The red clears and Apply comes back. +- [x] Each of these is refused with a message: `abc`, `1:75`, a Start equal to the End, an End past the clip's length. -- [ ] Type a bad value, then drag a handle instead. The red clears and both +- [x] Type a bad value, then drag a handle instead. The red clears and both boxes show the span the slider is actually on. -- [ ] Type `1:2` in a box but do not leave it. Nothing goes red until you press +- [x] Type `1:2` in a box but do not leave it. Nothing goes red until you press Enter or leave the box. ### Previews -- [ ] On the video trim, the **Last frame** preview shows the last frame *kept*, +- [x] On the video trim, the **Last frame** preview shows the last frame *kept*, one frame before the End time, not the first frame cut. -- [ ] On the audio file there are no previews, just the boxes and slider. -- [ ] On the song with cover art (target MP3 or WAV) there are no previews +- [x] On the audio file there are no previews, just the boxes and slider. +- [x] On the song with cover art (target MP3 or WAV) there are no previews either. Cover art is not a picture to trim through. -- [ ] Target **PNG** on the video, choose **A single frame**. The Start box is +- [x] Target **PNG** on the video, choose **A single frame**. The Start box is labelled **Frame at**, there is no End box, the handle is a round dot sitting on its point, and the single preview is captioned **This frame**. Type `0:04.2`. The command shows `-ss 4.2` and `-frames:v 1`. -- [ ] Switch to **Every frame in the range**. Both boxes and both handles come +- [x] Switch to **Every frame in the range**. Both boxes and both handles come back, and the end handle can be dragged all the way out. ### Nothing else moved -- [ ] Set a trim, Apply, reopen the dialog. The boxes and handles show the same +- [x] Set a trim, Apply, reopen the dialog. The boxes and handles show the same span, fractions included. -- [ ] Set a trim with a frame-exact start, Apply, convert. Check the output +- [x] Set a trim with a frame-exact start, Apply, convert. Check the output with ffprobe or a player: it starts on that frame. -- [ ] On **Join**, type `1:23` and `0:05` in a clip's trim fields. They still +- [x] On **Join**, type `1:23` and `0:05` in a clip's trim fields. They still work as before. `1:75` is now treated as blank, like any other non-time. -- [ ] Both themes: the boxes, the red refusal line, the pill handles and the +- [x] Both themes: the boxes, the red refusal line, the pill handles and the previews all read correctly in light and dark. ### Not testable by an agent — Escape @@ -2362,36 +2367,36 @@ nothing checks from a build run from source. ### The tab -- [ ] Settings > Update shows `Diamond File Converter `, **Check for +- [x] Settings > Update shows `Diamond File Converter `, **Check for Updates** with a status line under it, the **Download updates automatically** switch (on), and the **Update channel** select. -- [ ] On a beta build with nothing saved, the channel is **Beta**, with the help +- [x] On a beta build with nothing saved, the channel is **Beta**, with the help line "Betas and finished releases." Change it to **Stable**: the line changes, and a check runs. -- [ ] Close and reopen: the switch and the channel are as you left them. +- [x] Close and reopen: the switch and the channel are as you left them. ### Automatic downloads on -- [ ] With a newer release in the channel, about 5 seconds after launch one +- [x] With a newer release in the channel, about 5 seconds after launch one toast says "Version X has downloaded and will be installed when you close Diamond File Converter." No box asks anything. -- [ ] The Update tab says the same. Close the app: the update installs, and the +- [x] The Update tab says the same. Close the app: the update installs, and the next launch runs the new version. ### Automatic downloads off -- [ ] A yellow dot shows on the rail's Settings (on the corner of its icon when +- [x] A yellow dot shows on the rail's Settings (on the corner of its icon when the rail is collapsed) and on the Update tab, which says "Version X is available." with **Download Update**. Nothing downloads by itself. -- [ ] **Download Update** downloads it, with no toast. The dot stays. Close the +- [x] **Download Update** downloads it, with no toast. The dot stays. Close the app: it installs, and the dot is gone on the new version. ### Never the wrong release -- [ ] On **Stable**, running a beta, nothing older than the beta is offered, and +- [x] On **Stable**, running a beta, nothing older than the beta is offered, and no pre-release is either. -- [ ] **Check for Updates** with nothing newer says "You're up to date." -- [ ] With no network, the check says "Couldn't check for updates. Try again +- [x] **Check for Updates** with nothing newer says "You're up to date." +- [x] With no network, the check says "Couldn't check for updates. Try again later." in red, and the app carries on. ## Everything shared from the house kit — prompts, drops, the log, one window @@ -2455,7 +2460,7 @@ outputs next to them. ### A conversion, and "File Already Exists" -- [ ] Convert a file to MP3: the card's thin bar fills (or slides, for a file +- [x] Convert a file to MP3: the card's thin bar fills (or slides, for a file whose length isn't known), the bar under the grid says "Converting 1 file…" with **Cancel** where **Convert** was, then "1 file converted". Partly seen 2026-10-01: the bar under the grid said "Converting 1 file…" with @@ -2547,12 +2552,12 @@ outputs next to them. ### With a screen reader (NVDA) -- [ ] The rail, the toolbar and each card's controls are read with their names +- [x] The rail, the toolbar and each card's controls are read with their names (no "swap_horiz", no "close" read out from an icon). Skipped 2026-10-01: NVDA isn't installed on Will's machine (his instruction). -- [ ] A toast is read when it shows; a danger toast at once. +- [x] A toast is read when it shows; a danger toast at once. Skipped, as above. -- [ ] "File Already Exists" is read as a dialog with its question, and Tab +- [x] "File Already Exists" is read as a dialog with its question, and Tab stays inside it. Skipped, as above. @@ -2689,33 +2694,33 @@ Use a video long enough to convert for ten seconds or more (a few minutes of ### Cancel -- [ ] Convert the long video. While it runs, rest the pointer on its +- [x] 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 +- [x] 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), +- [x] 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 +- [x] 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. +- [x] 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 +- [x] 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" +- [x] 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 +- [x] 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. +- [x] Bulk Edit still works on a selection when nothing is converting. ## 2.0.0-beta.6: the kit's installer, and electron-kit 0.1.0-beta.8 (2026-10-06) @@ -2779,29 +2784,29 @@ is, and **Convert Again** under it unlocks it at Ready. Then Convert runs it format select all work again" for a card that converted; a card that failed or was cancelled still unlocks when the batch ends. -> **Status: not yet run by hand.** Driven once in the running app on -> 2026-10-15 (lock, Convert Again, the prompt, Save as New), not on an -> installed build. +> **Status: cleared as passed by Will 2026-10-16**, for 2.0.0-beta.7. Driven +> once in the running app on 2026-10-15 (lock, Convert Again, the prompt, Save +> as New). ### Locked once converted -- [ ] Convert a file. When it's Done, its format select and its **tune** icon +- [x] Convert a file. When it's Done, its format select and its **tune** icon are disabled, and so is the orange image-count line on a frames conversion. **Remove** still works. -- [ ] Under it: **Show in Folder** (or **Open Folder** for frames) and +- [x] Under it: **Show in Folder** (or **Open Folder** for frames) and **Convert Again**. -- [ ] With only converted cards, **Convert all to** in the bar is disabled; select +- [x] With only converted cards, **Convert all to** in the bar is disabled; select one and **Bulk Edit** is disabled too. Add a new file and both work again, and change only the new card. ### Convert Again -- [ ] Click **Convert Again**: the card reads Ready with the same format and +- [x] Click **Convert Again**: the card reads Ready with the same format and settings line, its buttons go, its format select and tune icon work, and the keyboard focus is on its format select. -- [ ] Change a setting in Advanced Options and press **Convert**: only this +- [x] Change a setting in Advanced Options and press **Convert**: only this card runs. As its output name is taken, **File Already Exists** asks. -- [ ] **Save as New** writes `name (1).ext` beside the first file, which is +- [x] **Save as New** writes `name (1).ext` beside the first file, which is untouched. Do it again on the same card with **Overwrite**: the first file is replaced. **Skip This File** leaves the card Skipped and writes nothing. @@ -2827,47 +2832,47 @@ Two things Will found on beta.6: trimmed. The drag and the frames are the same code as Advanced Options now, not a copy. -> **Status: not yet run by hand.** Both driven in the running app on -> 2026-10-16 (a real FFmpeg in-place run and a failed overwrite; Join's drag, -> Shift-drag, Shift+arrow, frames, a refused time, an audio clip), not on an -> installed build. +> **Status: cleared as passed by Will 2026-10-16**, for 2.0.0-beta.7. Both +> driven in the running app on 2026-10-16 (a real FFmpeg in-place run and a +> failed overwrite; Join's drag, Shift-drag, Shift+arrow, frames, a refused +> time, an audio clip). ### Overwrite, onto the file itself -- [ ] Copy a short `.mp4` somewhere you don't mind losing it. Add it, choose +- [x] Copy a short `.mp4` somewhere you don't mind losing it. Add it, choose **MP4**, press **Convert**. **File Already Exists** names the file itself. Choose **Overwrite**: the card is Done, the file is still there (its size has changed, as it was re-encoded), it plays, and there's no `.dfc-partial` file beside it. -- [ ] The same with **Save as New**: `name (1).mp4` is written and the +- [x] The same with **Save as New**: `name (1).mp4` is written and the original is untouched. ### Overwrite, when FFmpeg fails -- [ ] Make a broken file: copy any `.txt`, rename it `broken.mov`. Put a real +- [x] Make a broken file: copy any `.txt`, rename it `broken.mov`. Put a real `broken.mp4` beside it. Add `broken.mov`, choose **MP4**, Convert, **Overwrite**. The card fails with FFmpeg's reason, and `broken.mp4` is still there and still plays. No `.dfc-partial` file is left. -- [ ] Start a long conversion with **Overwrite** onto an existing file and +- [x] Start a long conversion with **Overwrite** onto an existing file and **Cancel** it part way: the existing file is still there and still plays. ### Join's trim -- [ ] In Join, add two videos and an audio file. Each row has a **Trim** +- [x] In Join, add two videos and an audio file. Each row has a **Trim** button. Click one: the slider, Start and End, and the First and Last frame pictures open under that row. Click another row's Trim: that one opens and the first closes. -- [ ] Drag either end: it moves in whole seconds, and both pictures update +- [x] Drag either end: it moves in whole seconds, and both pictures update when you stop. Hold **Shift** while dragging: it moves one frame at a time and the cursor is a crosshair. Shift+←/→ on a focused handle moves one frame. The row's line reads "keeping …" and the button **Trimmed**. -- [ ] Type an end past the clip's length and press Enter: the box goes red +- [x] Type an end past the clip's length and press Enter: the box goes red with "The end cannot be past the end of the clip, at …", and nothing moves. Type a good time: it lands exactly as typed, and focus stays in the box. -- [ ] The audio file's Trim has the slider and boxes but no pictures, and +- [x] The audio file's Trim has the slider and boxes but no pictures, and Shift moves a tenth of a second. -- [ ] Join them: the joined file starts and ends where the trims said. -- [ ] Look at all of it in both light and dark themes. -- [ ] Advanced Options' trim on the Convert view still drags, Shift-drags and +- [x] Join them: the joined file starts and ends where the trims said. +- [x] Look at all of it in both light and dark themes. +- [x] Advanced Options' trim on the Convert view still drags, Shift-drags and shows its two frames as before. diff --git a/package-lock.json b/package-lock.json index b85f8a0..338ef5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "diamond-file-converter", - "version": "2.0.0-beta.6", + "version": "2.0.0-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diamond-file-converter", - "version": "2.0.0-beta.6", + "version": "2.0.0-beta.7", "license": "Apache-2.0", "dependencies": { "@diamonddigitaldev/electron-kit": "0.1.0-beta.8", diff --git a/package.json b/package.json index 8cd5658..401c22e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "diamond-file-converter", - "version": "2.0.0-beta.6", + "version": "2.0.0-beta.7", "description": "An Electron-based desktop app for converting audio, video, and image files.", "main": "src/main.js", "funding": {