diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index a0899bb..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -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'] diff --git a/.gitignore b/.gitignore index 4a3dc9d..949575a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ neutralino.js # Neutralinojs related files .storage *.log +.tmp diff --git a/neutralino.config.json b/neutralino.config.json index 88248ae..b8f0163 100644 --- a/neutralino.config.json +++ b/neutralino.config.json @@ -17,6 +17,7 @@ "filesystem.getPathParts", "filesystem.createDirectory", "filesystem.getStats", + "storage.*", "debug.log" ], "modes": { diff --git a/solid-src/index.html b/solid-src/index.html index b237359..e22958f 100644 --- a/solid-src/index.html +++ b/solid-src/index.html @@ -5,7 +5,7 @@ - + Vencoder diff --git a/solid-src/src/App.tsx b/solid-src/src/App.tsx index b47ca4c..c9c9961 100644 --- a/solid-src/src/App.tsx +++ b/solid-src/src/App.tsx @@ -13,6 +13,7 @@ import { import { generateOutputCommand, getAvailableCodecs, + getLengthMicroseconds, playFile, videoFileExtensions, 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"]); +interface RunningProcessInfo { + process: Neutralino.os.SpawnedProcess; + file: string; + length: number; +} + function App() { const [windowFocused, setWindowFocused] = createSignal(true); const [displayedCodecs, setDisplayedCodecs]: Signal = @@ -40,10 +47,9 @@ function App() { const [showCommonCodecs, setShowCommonCodecs] = createSignal(true); const [selectedCodec, setSelectedCodec] = createSignal(); const [selectedEncoder, setSelectedEncoder] = createSignal(""); - const [runningProcess, setRunningProcess] = createSignal<{ - process: Neutralino.os.SpawnedProcess; - params: FFmpegParams; - }>(); + const [runningProcesses, setRunningProcesses] = createSignal< + RunningProcessInfo[] + >([]); let supportedCodecs: CodecInfo[] = []; let ffmpegParams: FFmpegParams = { vcodec: "" }; let successfulCount = 0; @@ -59,35 +65,32 @@ function App() { } 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) { - successfulCount += 1; - } else { - unsuccessfulCount += 1; - Neutralino.os.showNotification( - "File Encoding Failed", - `Encoding for file "${runningProcess()?.params.inputFile}" failed. Exit code ${evt.detail.data}.`, - ); - } + if (evt.detail.data === 0) { + successfulCount += 1; + } else { + unsuccessfulCount += 1; - if (successfulCount + unsuccessfulCount === totalCount) { - Neutralino.os.showNotification( - "File(s) encoded.", - `${successfulCount} files encoded successfully. ${unsuccessfulCount} failed.`, - ); - } - - console.log(`FFmpeg exited with code: ${evt.detail.data}`); - break; + if (evt.detail.data !== 255) { + Neutralino.os.showNotification( + "File Encoding Failed", + `Encoding for file "${runningProcesses()?.find((v) => v.process.id == evt.detail.id)?.file}" failed. Exit code ${evt.detail.data}.`, + ); + } } + + if (successfulCount + unsuccessfulCount === totalCount) { + Neutralino.os.showNotification( + "File(s) encoded.", + `${successfulCount} files encoded successfully. ${unsuccessfulCount} failed or cancelled.`, + ); + successfulCount = 0; + unsuccessfulCount = 0; + totalCount = 0; + } + + console.log(`FFmpeg exited with code: ${evt.detail.data}`); } onMount(async () => { @@ -221,7 +224,9 @@ function App() { setOutputCommand(generateOutputCommand(ffmpegParams)); }); - async function convertClip(clip: string) { + async function convertClip( + clip: string, + ): Promise { ffmpegParams.inputFile = clip; const fileName = (await Neutralino.filesystem.getPathParts(clip)).stem; @@ -264,12 +269,15 @@ function App() { } } catch (e) {} - setRunningProcess({ + const length = await getLengthMicroseconds(clip); + + return { process: await Neutralino.os.spawnProcess( generateOutputCommand(ffmpegParams), ), - params: { ...ffmpegParams }, - }); + file: clip, + length, + }; } async function convertAllClicked() { @@ -277,13 +285,39 @@ function App() { totalCount = list.length; - for (const clip of list) { - convertClip(clip); - } + const processes = (await Promise.all(list.map(convertClip))).filter( + (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() { - convertClip(selectedClip()); + async function convertSelectedClicked() { + const result = await convertClip(selectedClip()); + + if (result !== undefined) { + setRunningProcesses([]); + } } return ( diff --git a/solid-src/src/css/Kirigami.css b/solid-src/src/css/Kirigami.css index 38f74c5..9eb8d76 100644 --- a/solid-src/src/css/Kirigami.css +++ b/solid-src/src/css/Kirigami.css @@ -206,3 +206,7 @@ input[type="checkbox"] { .p-medium { padding: var(--k-medium-spacing); } + +.p-grid { + padding: var(--k-grid-unit); +} diff --git a/solid-src/src/css/index.css b/solid-src/src/css/index.css index d8353be..4d10e6c 100644 --- a/solid-src/src/css/index.css +++ b/solid-src/src/css/index.css @@ -62,6 +62,10 @@ h2 { margin: 0 0 0.25em 0; } +.w-full { + width: 100%; +} + .h-full { height: 100%; } diff --git a/solid-src/src/index.tsx b/solid-src/src/index.tsx index b86d5c3..8ac3ce2 100644 --- a/solid-src/src/index.tsx +++ b/solid-src/src/index.tsx @@ -8,6 +8,7 @@ import { clamp } from "./util/math.ts"; import convert, { type RGB } from "color-convert"; import { Route, Router } from "@solidjs/router"; import Settings from "./pages/Settings.tsx"; +import ProgressPage from "./pages/ProgressPage.tsx"; const root = document.getElementById("root"); @@ -44,6 +45,7 @@ render( + ), root!, diff --git a/solid-src/src/pages/ProgressPage.tsx b/solid-src/src/pages/ProgressPage.tsx new file mode 100644 index 0000000..991ad8d --- /dev/null +++ b/solid-src/src/pages/ProgressPage.tsx @@ -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([]); + const progressObject: { + [key: string]: ProgressInfo; + } = {}; + const [progressList, setProgressList] = createSignal([]); + 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 ( +
+
+
+
+ Progress +
+
+
+ + Processes finished. You can close this window. + + + {(item, _) => ( +
+ +
+
+ +
+
+ {Math.min( + Math.round(item().percentage), + 100, + )} + % +
+
+
+ )} +
+
+ + + +
+
+ ); +} + +export default ProgressPage; diff --git a/solid-src/src/util/ffmpeg.ts b/solid-src/src/util/ffmpeg.ts index 5a7196b..b8f13be 100644 --- a/solid-src/src/util/ffmpeg.ts +++ b/solid-src/src/util/ffmpeg.ts @@ -127,3 +127,18 @@ ffmpeg -y -hwaccel auto ${commonOpts} ${ params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k` } -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); +}