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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
327 changes: 220 additions & 107 deletions docs/MANUAL-TESTING.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "diamond-file-converter",
"version": "2.0.0-beta.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": {
Expand Down
49 changes: 49 additions & 0 deletions src/core/display.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -781,6 +827,9 @@
checkTrimEntry,
describeMeta,
describeStatus,
isLocked,
reopen,
cardActions,
summarise,
overallProgress,
iconForKind,
Expand Down
13 changes: 13 additions & 0 deletions src/core/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -163,5 +175,6 @@ module.exports = {
sanitiseName,
resolveOutputDir,
resolveOutputPath,
getStagingPath,
ensureParentDir,
};
46 changes: 39 additions & 7 deletions src/core/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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();
});
Expand All @@ -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,
Expand All @@ -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}.`,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading