Progress page

This commit is contained in:
2025-07-30 19:46:51 +07:00
parent 43e29b1cae
commit 7ab5bf6bf6
10 changed files with 300 additions and 52 deletions
-12
View File
@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: shalithasuranga
patreon: shalithasuranga
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1
View File
@@ -11,3 +11,4 @@ neutralino.js
# Neutralinojs related files # Neutralinojs related files
.storage .storage
*.log *.log
.tmp
+1
View File
@@ -17,6 +17,7 @@
"filesystem.getPathParts", "filesystem.getPathParts",
"filesystem.createDirectory", "filesystem.createDirectory",
"filesystem.getStats", "filesystem.getStats",
"storage.*",
"debug.log" "debug.log"
], ],
"modes": { "modes": {
+1 -1
View File
@@ -5,7 +5,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="http://localhost:37503/__neutralino_globals.js"></script> <script src="http://localhost:35731/__neutralino_globals.js"></script>
<title>Vencoder</title> <title>Vencoder</title>
</head> </head>
+59 -25
View File
@@ -13,6 +13,7 @@ import {
import { import {
generateOutputCommand, generateOutputCommand,
getAvailableCodecs, getAvailableCodecs,
getLengthMicroseconds,
playFile, playFile,
videoFileExtensions, videoFileExtensions,
type CodecInfo, type CodecInfo,
@@ -26,6 +27,12 @@ import TrashEmpty from "./assets/breeze/actions/16/trash-empty.svg";
const commonCodecs = new Set(["h264", "hevc", "vp8", "vp9", "av1", "dnxhd"]); const commonCodecs = new Set(["h264", "hevc", "vp8", "vp9", "av1", "dnxhd"]);
interface RunningProcessInfo {
process: Neutralino.os.SpawnedProcess;
file: string;
length: number;
}
function App() { function App() {
const [windowFocused, setWindowFocused] = createSignal(true); const [windowFocused, setWindowFocused] = createSignal(true);
const [displayedCodecs, setDisplayedCodecs]: Signal<CodecInfo[]> = const [displayedCodecs, setDisplayedCodecs]: Signal<CodecInfo[]> =
@@ -40,10 +47,9 @@ function App() {
const [showCommonCodecs, setShowCommonCodecs] = createSignal(true); const [showCommonCodecs, setShowCommonCodecs] = createSignal(true);
const [selectedCodec, setSelectedCodec] = createSignal<CodecInfo>(); const [selectedCodec, setSelectedCodec] = createSignal<CodecInfo>();
const [selectedEncoder, setSelectedEncoder] = createSignal(""); const [selectedEncoder, setSelectedEncoder] = createSignal("");
const [runningProcess, setRunningProcess] = createSignal<{ const [runningProcesses, setRunningProcesses] = createSignal<
process: Neutralino.os.SpawnedProcess; RunningProcessInfo[]
params: FFmpegParams; >([]);
}>();
let supportedCodecs: CodecInfo[] = []; let supportedCodecs: CodecInfo[] = [];
let ffmpegParams: FFmpegParams = { vcodec: "" }; let ffmpegParams: FFmpegParams = { vcodec: "" };
let successfulCount = 0; let successfulCount = 0;
@@ -59,35 +65,32 @@ function App() {
} }
function handleSpawnedProcessEvents(evt: CustomEvent) { function handleSpawnedProcessEvents(evt: CustomEvent) {
if (runningProcess()?.process.id !== evt.detail.id) return; if (evt.detail.action !== "exit") return;
switch (evt.detail.action) {
case "stdOut":
console.log(evt.detail.data);
break;
case "stdErr":
break;
case "exit":
if (evt.detail.data === 0) { if (evt.detail.data === 0) {
successfulCount += 1; successfulCount += 1;
} else { } else {
unsuccessfulCount += 1; unsuccessfulCount += 1;
if (evt.detail.data !== 255) {
Neutralino.os.showNotification( Neutralino.os.showNotification(
"File Encoding Failed", "File Encoding Failed",
`Encoding for file "${runningProcess()?.params.inputFile}" failed. Exit code ${evt.detail.data}.`, `Encoding for file "${runningProcesses()?.find((v) => v.process.id == evt.detail.id)?.file}" failed. Exit code ${evt.detail.data}.`,
); );
} }
}
if (successfulCount + unsuccessfulCount === totalCount) { if (successfulCount + unsuccessfulCount === totalCount) {
Neutralino.os.showNotification( Neutralino.os.showNotification(
"File(s) encoded.", "File(s) encoded.",
`${successfulCount} files encoded successfully. ${unsuccessfulCount} failed.`, `${successfulCount} files encoded successfully. ${unsuccessfulCount} failed or cancelled.`,
); );
successfulCount = 0;
unsuccessfulCount = 0;
totalCount = 0;
} }
console.log(`FFmpeg exited with code: ${evt.detail.data}`); console.log(`FFmpeg exited with code: ${evt.detail.data}`);
break;
}
} }
onMount(async () => { onMount(async () => {
@@ -221,7 +224,9 @@ function App() {
setOutputCommand(generateOutputCommand(ffmpegParams)); setOutputCommand(generateOutputCommand(ffmpegParams));
}); });
async function convertClip(clip: string) { async function convertClip(
clip: string,
): Promise<RunningProcessInfo | undefined> {
ffmpegParams.inputFile = clip; ffmpegParams.inputFile = clip;
const fileName = (await Neutralino.filesystem.getPathParts(clip)).stem; const fileName = (await Neutralino.filesystem.getPathParts(clip)).stem;
@@ -264,12 +269,15 @@ function App() {
} }
} catch (e) {} } catch (e) {}
setRunningProcess({ const length = await getLengthMicroseconds(clip);
return {
process: await Neutralino.os.spawnProcess( process: await Neutralino.os.spawnProcess(
generateOutputCommand(ffmpegParams), generateOutputCommand(ffmpegParams),
), ),
params: { ...ffmpegParams }, file: clip,
}); length,
};
} }
async function convertAllClicked() { async function convertAllClicked() {
@@ -277,13 +285,39 @@ function App() {
totalCount = list.length; totalCount = list.length;
for (const clip of list) { const processes = (await Promise.all(list.map(convertClip))).filter(
convertClip(clip); (v) => v !== undefined,
} );
setRunningProcesses(processes);
await Neutralino.storage.setData(
"filesBeingProcessed",
JSON.stringify(
processes.map((v) => ({
id: v.process.id,
in: v.file,
len: v.length,
})),
),
);
await Neutralino.window.create(`${window.location.href}progress`, {
width: 600,
height: 400,
x: 120,
y: 120,
injectGlobals: true,
maximizable: false,
});
} }
function convertSelectedClicked() { async function convertSelectedClicked() {
convertClip(selectedClip()); const result = await convertClip(selectedClip());
if (result !== undefined) {
setRunningProcesses([]);
}
} }
return ( return (
+4
View File
@@ -206,3 +206,7 @@ input[type="checkbox"] {
.p-medium { .p-medium {
padding: var(--k-medium-spacing); padding: var(--k-medium-spacing);
} }
.p-grid {
padding: var(--k-grid-unit);
}
+4
View File
@@ -62,6 +62,10 @@ h2 {
margin: 0 0 0.25em 0; margin: 0 0 0.25em 0;
} }
.w-full {
width: 100%;
}
.h-full { .h-full {
height: 100%; height: 100%;
} }
+2
View File
@@ -8,6 +8,7 @@ import { clamp } from "./util/math.ts";
import convert, { type RGB } from "color-convert"; import convert, { type RGB } from "color-convert";
import { Route, Router } from "@solidjs/router"; import { Route, Router } from "@solidjs/router";
import Settings from "./pages/Settings.tsx"; import Settings from "./pages/Settings.tsx";
import ProgressPage from "./pages/ProgressPage.tsx";
const root = document.getElementById("root"); const root = document.getElementById("root");
@@ -44,6 +45,7 @@ render(
<Router> <Router>
<Route path="/" component={App} /> <Route path="/" component={App} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route path="/progress" component={ProgressPage} />
</Router> </Router>
), ),
root!, root!,
+199
View File
@@ -0,0 +1,199 @@
import { events, os, storage } from "@neutralinojs/lib";
import { createSignal, onMount, onCleanup, Show, Index } from "solid-js";
interface TargetFile {
id: string;
in: string;
len: number;
}
interface ProgressInfo {
filename: string;
percentage: number;
}
interface FFmpegProgressInfo {
bitrate: string;
drop_frames: string;
dup_frames: string;
fps: string;
frame: string;
out_time: string;
out_time_ms: string;
out_time_us: string;
progress: string;
speed: string;
total_size: string;
}
function ProgressPage() {
const [windowFocused, setWindowFocused] = createSignal(true);
const [runningProcesses, setRunningProcesses] = createSignal<
os.SpawnedProcess[]
>([]);
const [finished, setFinished] = createSignal(false);
const [fileInfo, setFileInfo] = createSignal<TargetFile[]>([]);
const progressObject: {
[key: string]: ProgressInfo;
} = {};
const [progressList, setProgressList] = createSignal<ProgressInfo[]>([]);
const [isCancelling, setIsCancelling] = createSignal(false);
function windowIsFocused() {
setWindowFocused(false);
}
function windowUnfocused() {
setWindowFocused(true);
}
function handleSpawnedProcessEvents(evt: CustomEvent) {
switch (evt.detail.action) {
case "stdOut":
const info: FFmpegProgressInfo = Object.fromEntries(
(evt.detail.data as string)
.split("\n")
.map((v) => v.split("=")),
);
const file = fileInfo().find((v) => v.id === evt.detail.id);
if (file === undefined) return;
progressObject[evt.detail.id] = {
filename: file.in,
percentage: (parseInt(info.out_time_us) / file.len) * 100,
};
setProgressList(Object.values(progressObject));
break;
case "stdErr":
break;
case "exit":
console.log(`FFmpeg exited with code: ${evt.detail.data}`);
os.getSpawnedProcesses().then((processes) => {
if (processes.length === 0) {
setFinished(true);
}
setRunningProcesses(processes);
});
break;
}
}
onMount(async () => {
events.on("windowFocus", windowIsFocused);
events.on("windowBlur", windowUnfocused);
events.on("spawnedProcess", handleSpawnedProcessEvents);
const processes = await os.getSpawnedProcesses();
setRunningProcesses(processes);
const storedFileInfo: TargetFile[] = JSON.parse(
await storage.getData("filesBeingProcessed"),
);
setFileInfo(storedFileInfo);
for (let i = 0; i < processes.length; i++) {
progressObject[processes[i].id] = {
filename: storedFileInfo[i].in,
percentage: 0,
};
}
setProgressList(Object.values(progressObject));
});
onCleanup(() => {
events.off("windowFocus", windowIsFocused);
events.off("windowBlur", windowUnfocused);
events.off("spawnedProcess", handleSpawnedProcessEvents);
});
async function cancelBtnClicked() {
setIsCancelling(true);
const processes = runningProcesses();
for (const process of processes) {
try {
await os.updateSpawnedProcess(process.id, "exit");
} catch (e) {
console.error(e);
}
}
setFinished(true);
}
return (
<main class="row flex-col">
<div class="container row flex-col" style={{ flex: "1" }}>
<header
class={`k-page-header ${windowFocused() ? "" : "window-blur"}`}
>
<div class="page-title" role="heading">
Progress
</div>
</header>
<div
class="p-grid col row flex-col"
style={{ overflow: "scroll" }}
>
<Show when={finished()}>
Processes finished. You can close this window.
</Show>
<Index each={progressList()}>
{(item, _) => (
<div
class="row flex-col"
style={{
"padding-bottom": "var(--k-grid-unit)",
}}
>
<label>{item().filename}</label>
<div
class="grid"
style={{
"grid-template-columns": "90% 10%",
}}
>
<div class="row justify-content-center align-items-center">
<progress
class="col"
value={item().percentage}
max="100"
/>
</div>
<div class="row justify-content-center align-items-center">
{Math.min(
Math.round(item().percentage),
100,
)}
%
</div>
</div>
</div>
)}
</Index>
</div>
<Show when={!finished()}>
<footer
class="p-medium row"
style={{ "align-items": "end" }}
>
<button
class="k-button"
disabled={isCancelling()}
onclick={cancelBtnClicked}
>
Cancel
</button>
</footer>
</Show>
</div>
</main>
);
}
export default ProgressPage;
+15
View File
@@ -127,3 +127,18 @@ ffmpeg -y -hwaccel auto ${commonOpts} ${
params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k` params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k`
} -progress - "${params.outputFile ?? "{output}"}"`; } -progress - "${params.outputFile ?? "{output}"}"`;
} }
export async function getLengthMicroseconds(target: string) {
const result = await Neutralino.os.execCommand(
`ffprobe -v quiet -of json=c=1 -show_entries format=duration -sexagesimal "${target}"`,
);
const rawDuration = JSON.parse(result.stdOut)["format"]["duration"].split(
":",
) as string[];
const hours = parseInt(rawDuration[0]);
const minutes = hours * 60 + parseInt(rawDuration[1]);
const seconds = minutes * 60 + parseFloat(rawDuration[2]);
return Math.trunc(seconds * 1000000);
}