Really basic functionality

This commit is contained in:
2025-07-29 16:45:17 +07:00
commit 43e29b1cae
38 changed files with 3441 additions and 0 deletions
+489
View File
@@ -0,0 +1,489 @@
import { events } from "@neutralinojs/lib";
import {
createEffect,
createSignal,
For,
Match,
onCleanup,
onMount,
Show,
Switch,
type Signal,
} from "solid-js";
import {
generateOutputCommand,
getAvailableCodecs,
playFile,
videoFileExtensions,
type CodecInfo,
type FFmpegParams,
} from "./util/ffmpeg";
import Neutralino from "@neutralinojs/lib";
import H264Options from "./components/H264Options";
import Configure from "./assets/breeze/actions/16/configure.svg";
import PlaybackStart from "./assets/breeze/actions/16/media-playback-start.svg";
import TrashEmpty from "./assets/breeze/actions/16/trash-empty.svg";
const commonCodecs = new Set(["h264", "hevc", "vp8", "vp9", "av1", "dnxhd"]);
function App() {
const [windowFocused, setWindowFocused] = createSignal(true);
const [displayedCodecs, setDisplayedCodecs]: Signal<CodecInfo[]> =
createSignal([] as CodecInfo[]);
const [fileList, setFileList] = createSignal([
"/home/satakunu/Videos/litetask_demo.mkv",
]);
const [selectedClip, setSelectedClip] = createSignal("");
const [outputCommand, setOutputCommand] = createSignal(
"ffmpeg -i {filename}",
);
const [showCommonCodecs, setShowCommonCodecs] = createSignal(true);
const [selectedCodec, setSelectedCodec] = createSignal<CodecInfo>();
const [selectedEncoder, setSelectedEncoder] = createSignal("");
const [runningProcess, setRunningProcess] = createSignal<{
process: Neutralino.os.SpawnedProcess;
params: FFmpegParams;
}>();
let supportedCodecs: CodecInfo[] = [];
let ffmpegParams: FFmpegParams = { vcodec: "" };
let successfulCount = 0;
let unsuccessfulCount = 0;
let totalCount = 0;
function windowIsFocused() {
setWindowFocused(true);
}
function windowUnfocused() {
setWindowFocused(false);
}
function handleSpawnedProcessEvents(evt: CustomEvent) {
if (runningProcess()?.process.id !== evt.detail.id) 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 (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;
}
}
onMount(async () => {
events.on("windowFocus", windowIsFocused);
events.on("windowBlur", windowUnfocused);
events.on("spawnedProcess", handleSpawnedProcessEvents);
supportedCodecs = await getAvailableCodecs();
filterDisplayedCodecs();
const firstCodec = displayedCodecs()[0];
setSelectedCodec(firstCodec);
setSelectedEncoder(firstCodec.encoders[0]);
});
onCleanup(() => {
events.off("windowFocus", windowIsFocused);
events.off("windowBlur", windowUnfocused);
events.off("spawnedProcess", handleSpawnedProcessEvents);
});
function removeBtnClicked() {
if (selectedClip() === "") return;
const list = fileList();
const targetClip = selectedClip();
setFileList(list.filter((v) => v !== targetClip));
setSelectedClip("");
}
function removeAllBtnClicked() {
setFileList([]);
setSelectedClip("");
}
function playBtnClicked() {
playFile(selectedClip());
}
async function openBtnClicked() {
const filePaths = await Neutralino.os.showOpenDialog("Select Videos", {
multiSelections: true,
filters: [
{
extensions: ["mp4", "mkv", "mov", "webm"],
name: "Common Video Files",
},
{
extensions: ["*"],
name: "All Files",
},
],
});
setFileList(Array.from(new Set([...fileList(), ...filePaths])));
}
function filterDisplayedCodecs() {
if (showCommonCodecs()) {
setDisplayedCodecs(
supportedCodecs.filter((v) => commonCodecs.has(v.shortName)),
);
return;
}
setDisplayedCodecs(supportedCodecs);
}
function showCommonCodecsChanged(e: InputEvent) {
const newValue = (e.target as HTMLInputElement).checked;
setShowCommonCodecs(newValue);
filterDisplayedCodecs();
}
function selectedCodecsChanged(e: InputEvent) {
const newValue = (e.target as HTMLInputElement).value;
const codecObj = displayedCodecs().find(
(v) => v.shortName === newValue,
);
if (newValue !== "h264" && newValue !== "hevc") {
ffmpegParams.twopass = false;
}
setSelectedCodec(codecObj);
let encoder = newValue;
if (codecObj?.encoders.length !== 0) {
encoder = codecObj?.encoders[0] ?? "";
}
setSelectedEncoder(encoder);
}
function onParametersChanged(key: string, value: any) {
// @ts-ignore
ffmpegParams[key] = value;
setOutputCommand(generateOutputCommand(ffmpegParams));
}
function settingsBtnPressed() {
Neutralino.window.create(`${window.location.href}settings`, {
width: 800,
height: 600,
x: 120,
y: 120,
injectGlobals: true,
});
}
createEffect(() => {
let encoder: string | undefined = selectedEncoder();
if (encoder === "") {
encoder = undefined;
}
ffmpegParams = {
vcodec: selectedCodec()?.shortName ?? "",
encoder,
acodec: ffmpegParams.acodec,
abitrate: ffmpegParams.abitrate,
crf: ffmpegParams.crf,
doNotUseAn: ffmpegParams.doNotUseAn,
faststart: ffmpegParams.faststart,
hwaccel: ffmpegParams.hwaccel,
inputFile: undefined,
preset: ffmpegParams.preset,
twopass: ffmpegParams.twopass,
vbitrate: ffmpegParams.vbitrate,
};
setOutputCommand(generateOutputCommand(ffmpegParams));
});
async function convertClip(clip: string) {
ffmpegParams.inputFile = clip;
const fileName = (await Neutralino.filesystem.getPathParts(clip)).stem;
const fileExt =
videoFileExtensions[selectedCodec()?.shortName ?? ""] ?? "";
switch (window.NL_OS) {
case "Linux":
ffmpegParams.outputFile = `${await Neutralino.os.getEnv("HOME")}/Vencoder/${fileName}.${fileExt}`;
break;
case "Windows":
ffmpegParams.outputFile = `${await Neutralino.os.getEnv("HOMEPATH")}\\Vencoder\\${fileName}.${fileExt}`;
break;
}
const outputDir = (
await Neutralino.filesystem.getPathParts(
ffmpegParams.outputFile ?? "",
)
).parentPath;
try {
await Neutralino.filesystem.getStats(outputDir);
} catch (e) {
console.log(e);
await Neutralino.filesystem.createDirectory(outputDir);
}
try {
await Neutralino.filesystem.getStats(ffmpegParams.outputFile ?? "");
const userAnswer = await Neutralino.os.showMessageBox(
"File already exists",
`A file at ${ffmpegParams.outputFile} already exists. Would you like to overwrite it?`,
Neutralino.os.MessageBoxChoice.YES_NO,
Neutralino.os.Icon.QUESTION,
);
if (userAnswer === "NO") {
return;
}
} catch (e) {}
setRunningProcess({
process: await Neutralino.os.spawnProcess(
generateOutputCommand(ffmpegParams),
),
params: { ...ffmpegParams },
});
}
async function convertAllClicked() {
const list = fileList();
totalCount = list.length;
for (const clip of list) {
convertClip(clip);
}
}
function convertSelectedClicked() {
convertClip(selectedClip());
}
return (
<main class="row flex-col">
<div class="container" style={{ flex: "1" }}>
<div class="row h-full">
<div class="row flex-col h-full">
<header
class={`k-page-header k-rborder ${windowFocused() ? "" : "window-blur"}`}
>
<div class="page-title">Vencoder</div>
</header>
<div
class="row flex-col gap2 k-white-sidebar k-rborder h-full"
style={{ padding: "8px" }}
>
<ul class="k-list-view bordered col">
<For each={fileList()}>
{(item, _) => (
<li
class={
item == selectedClip()
? "selected"
: ""
}
onclick={() =>
setSelectedClip(item)
}
>
{item}
</li>
)}
</For>
</ul>
<div class="row gap2">
<button
onclick={openBtnClicked}
class="k-button"
>
Open...
</button>
<button
onclick={removeAllBtnClicked}
class="k-button"
>
Remove All
</button>
<button
disabled={selectedClip() === ""}
onclick={removeBtnClicked}
class="icon-button k-button"
>
<img
src={TrashEmpty}
alt="Remove Selected Video"
/>
</button>
<button
disabled={selectedClip() === ""}
onclick={playBtnClicked}
class="icon-button k-button"
>
<img
src={PlaybackStart}
alt="Preview Selected Video"
/>
</button>
<button
class="icon-button k-button"
onclick={settingsBtnPressed}
>
<img
src={Configure}
alt="Configure Vencoder"
/>
</button>
</div>
</div>
</div>
<div class="row flex-col h-full" style={{ width: "100%" }}>
<header
class={`k-page-header ${windowFocused() ? "" : "window-blur"}`}
>
<div class="page-title">Conversion Settings</div>
</header>
<div
class="col row flex-col"
style={{
padding:
"var(--k-grid-unit) var(--k-small-spacing)",
flex: "1",
}}
>
<div>
<form
class="k-form"
onsubmit={(e) => e.preventDefault()}
>
<label for="targetCodec">Codec</label>
<select
class="k-dropdown"
id="targetCodec"
oninput={selectedCodecsChanged}
>
<For each={displayedCodecs()}>
{(item, _) => (
<option value={item.shortName}>
{item.description}
</option>
)}
</For>
</select>
<div></div>
<div class="checkbox-container">
<input
type="checkbox"
name="commonCodecs"
id="commonCodecs"
oninput={showCommonCodecsChanged}
checked
/>
<label for="commonCodecs">
Only show common codecs
</label>
</div>
<Show
when={
selectedCodec()?.encoders.length !==
0
}
>
<label>Encoder</label>
<select
name="videoEncoder"
id="videoEncoder"
class="k-dropdown"
value={selectedEncoder()}
oninput={(e) =>
setSelectedEncoder(
e.target.value,
)
}
>
<For
each={selectedCodec()?.encoders}
>
{(item, _) => (
<option>{item}</option>
)}
</For>
</select>
</Show>
</form>
<Switch fallback={<div></div>}>
<Match
when={
selectedCodec()?.shortName ===
"h264" ||
selectedCodec()?.shortName ===
"hevc"
}
>
<H264Options
codec={selectedCodec()}
params={ffmpegParams}
onParamChanged={onParametersChanged}
/>
</Match>
</Switch>
</div>
<div class="row flex-col p-medium">
<label for="outputCommand">Command</label>
<pre
id="outputCommand"
class="k-text-field w-full col"
>
{outputCommand()}
</pre>
</div>
</div>
<footer class="k-page-footer row gap2">
<button
class="k-button"
onclick={convertAllClicked}
>
Convert All
</button>
<button
class="k-button"
onclick={convertSelectedClicked}
disabled={selectedClip() === ""}
>
Convert Selected
</button>
</footer>
</div>
</div>
</div>
</main>
);
}
export default App;