658 lines
24 KiB
TypeScript
658 lines
24 KiB
TypeScript
import { events } from "@neutralinojs/lib";
|
|
import {
|
|
createEffect,
|
|
createSignal,
|
|
For,
|
|
Match,
|
|
onCleanup,
|
|
onMount,
|
|
Show,
|
|
Switch,
|
|
type Signal,
|
|
} from "solid-js";
|
|
import {
|
|
generateOutputCommand,
|
|
getAvailableCodecs,
|
|
getLengthMicroseconds,
|
|
playFile,
|
|
videoFileExtensions,
|
|
type CodecInfo,
|
|
type CodecList,
|
|
type FFmpegParams,
|
|
} from "./util/ffmpeg";
|
|
import Neutralino from "@neutralinojs/lib";
|
|
import H264Options from "./components/H264Options";
|
|
import { openFile } from "./util/oshelper";
|
|
import { getTemporaryFilePath, getVencoderFolder } from "./util/path";
|
|
import { generateRandomString } from "./util/string";
|
|
import "./css/icons.css";
|
|
import BreezeIcon from "./components/BreezeIcon";
|
|
import AV1Options from "./components/AV1Options";
|
|
import DNxHDOptions from "./components/DNxHDOptions";
|
|
|
|
const commonCodecs = new Set(["h264", "hevc", "vp8", "vp9", "av1", "dnxhd"]);
|
|
|
|
interface RunningProcessInfo {
|
|
process: Neutralino.SpawnedProcess;
|
|
file: string;
|
|
length: number;
|
|
}
|
|
|
|
function App() {
|
|
const [windowFocused, setWindowFocused] = createSignal(true);
|
|
const [displayedCodecs, setDisplayedCodecs]: Signal<CodecInfo[]> =
|
|
createSignal([] as CodecInfo[]);
|
|
const [audioCodecList, setAudioCodecList] = createSignal([] as CodecInfo[]);
|
|
const [fileList, setFileList] = createSignal([] as string[]);
|
|
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 [runningProcesses, setRunningProcesses] = createSignal<
|
|
RunningProcessInfo[]
|
|
>([]);
|
|
const [customFileExt, setCustomFileExt] = createSignal("");
|
|
const [globalopts, setGlobalopts] = createSignal("");
|
|
const [inputopts, setInputopts] = createSignal("");
|
|
const [outputopts, setOutputopts] = createSignal("");
|
|
const [audioCodec, setAudioCodec] = createSignal("copy");
|
|
const logs: { [id: number]: string[] } = {};
|
|
let supportedCodecs: CodecList = { vcodecs: [], acodecs: [] };
|
|
let ffmpegParams: FFmpegParams = {
|
|
vcodec: "",
|
|
useropts: {
|
|
global: "",
|
|
input: "",
|
|
output: "",
|
|
},
|
|
};
|
|
let successfulCount = 0;
|
|
let unsuccessfulCount = 0;
|
|
let totalCount = 0;
|
|
|
|
function windowIsFocused() {
|
|
setWindowFocused(true);
|
|
}
|
|
|
|
function windowUnfocused() {
|
|
setWindowFocused(false);
|
|
}
|
|
|
|
function handleSpawnedProcessEvents(evt: CustomEvent) {
|
|
switch (evt.detail.action) {
|
|
case "stdErr":
|
|
logs[evt.detail.id].push(evt.detail.data);
|
|
break;
|
|
case "exit":
|
|
if (evt.detail.data === 0) {
|
|
successfulCount += 1;
|
|
} else {
|
|
unsuccessfulCount += 1;
|
|
|
|
// If the exit code isn't 255 (the exit code of the program exiting because of cancellation)
|
|
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}.`,
|
|
);
|
|
|
|
const tempFilename = `${getTemporaryFilePath()}/vencoder-ffmpeg-${generateRandomString(8)}.log`;
|
|
Neutralino.filesystem.writeFile(
|
|
tempFilename,
|
|
logs[evt.detail.id].join("\n"),
|
|
);
|
|
openFile(tempFilename);
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
events.on("windowFocus", windowIsFocused);
|
|
events.on("windowBlur", windowUnfocused);
|
|
events.on("spawnedProcess", handleSpawnedProcessEvents);
|
|
|
|
supportedCodecs = await getAvailableCodecs();
|
|
filterDisplayedCodecs();
|
|
setAudioCodecList(supportedCodecs.acodecs);
|
|
|
|
const firstCodec = displayedCodecs()[0];
|
|
|
|
ffmpegParams.vcodec = firstCodec.shortName;
|
|
ffmpegParams.encoder = firstCodec.encoders[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.vcodecs.filter((v) =>
|
|
commonCodecs.has(v.shortName),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setDisplayedCodecs(supportedCodecs.vcodecs);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
ffmpegParams = {
|
|
vcodec: codecObj?.shortName ?? "",
|
|
useropts: {
|
|
global: "",
|
|
input: "",
|
|
output: "",
|
|
},
|
|
};
|
|
|
|
let encoder = newValue;
|
|
if (codecObj?.encoders.length !== 0) {
|
|
encoder = codecObj?.encoders[0] ?? "";
|
|
}
|
|
ffmpegParams.encoder = encoder;
|
|
setSelectedCodec(codecObj);
|
|
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: audioCodec(),
|
|
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,
|
|
useropts: {
|
|
global: globalopts(),
|
|
input: inputopts(),
|
|
output: outputopts(),
|
|
},
|
|
};
|
|
|
|
setOutputCommand(generateOutputCommand(ffmpegParams));
|
|
});
|
|
|
|
async function convertClip(
|
|
clip: string,
|
|
): Promise<RunningProcessInfo | undefined> {
|
|
ffmpegParams.inputFile = clip;
|
|
|
|
const fileName = (await Neutralino.filesystem.getPathParts(clip)).stem;
|
|
|
|
const customExt = customFileExt();
|
|
|
|
const fileExt =
|
|
customExt === ""
|
|
? videoFileExtensions[selectedCodec()?.shortName ?? ""]
|
|
: customExt;
|
|
|
|
ffmpegParams.outputFile = `${await getVencoderFolder()}${fileName}.${fileExt}`;
|
|
|
|
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.MessageBoxChoice.YES_NO,
|
|
Neutralino.Icon.QUESTION,
|
|
);
|
|
|
|
if (userAnswer === "NO") {
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
|
|
const length = await getLengthMicroseconds(clip);
|
|
|
|
return {
|
|
process: await Neutralino.os.spawnProcess(
|
|
generateOutputCommand(ffmpegParams),
|
|
),
|
|
file: clip,
|
|
length,
|
|
};
|
|
}
|
|
|
|
async function convertAllClicked() {
|
|
const list = fileList();
|
|
|
|
totalCount = list.length;
|
|
|
|
const processes = (await Promise.all(list.map(convertClip))).filter(
|
|
(v) => v !== undefined,
|
|
);
|
|
|
|
setRunningProcesses(processes);
|
|
|
|
processes.forEach((v) => (logs[v.process.id] = []));
|
|
|
|
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,
|
|
enableInspector: false,
|
|
});
|
|
}
|
|
|
|
async function convertSelectedClicked() {
|
|
const result = await convertClip(selectedClip());
|
|
|
|
if (result === undefined) {
|
|
return;
|
|
}
|
|
|
|
console.log(result);
|
|
|
|
totalCount = 1;
|
|
|
|
setRunningProcesses([result]);
|
|
|
|
logs[result.process.id] = [];
|
|
|
|
await Neutralino.storage.setData(
|
|
"filesBeingProcessed",
|
|
JSON.stringify([
|
|
{
|
|
id: result.process.id,
|
|
in: result.file,
|
|
len: result.length,
|
|
},
|
|
]),
|
|
);
|
|
|
|
await Neutralino.window.create(`${window.location.href}progress`, {
|
|
width: 600,
|
|
height: 400,
|
|
x: 120,
|
|
y: 120,
|
|
injectGlobals: true,
|
|
maximizable: false,
|
|
enableInspector: false,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<main class="row">
|
|
<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"
|
|
>
|
|
<BreezeIcon
|
|
icon="b b-trash-empty"
|
|
alt="Remove Selected Video"
|
|
/>
|
|
</button>
|
|
<button
|
|
disabled={selectedClip() === ""}
|
|
onclick={playBtnClicked}
|
|
class="icon-button k-button"
|
|
>
|
|
<BreezeIcon
|
|
icon="playback-start"
|
|
alt="Preview Selected Video"
|
|
/>
|
|
</button>
|
|
<button
|
|
class="icon-button k-button"
|
|
onclick={settingsBtnPressed}
|
|
>
|
|
<BreezeIcon icon="configure" alt="Configure" />
|
|
</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="page-content">
|
|
<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>
|
|
<label for="fileExt">File Extension</label>
|
|
<input
|
|
type="text"
|
|
name="fileExt"
|
|
id="fileExt"
|
|
title="File extension without the dot. Leave blank to guess from codec."
|
|
value={customFileExt()}
|
|
oninput={(e) =>
|
|
setCustomFileExt(e.target.value)
|
|
}
|
|
placeholder="Leave blank to guess from codec"
|
|
/>
|
|
<Show when={selectedCodec()?.encoders.length !== 0}>
|
|
<label for="videoEncoder">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>
|
|
<Match when={selectedCodec()?.shortName === "av1"}>
|
|
<AV1Options
|
|
codec={selectedCodec()}
|
|
encoder={selectedEncoder()}
|
|
params={ffmpegParams}
|
|
onParamChanged={onParametersChanged}
|
|
/>
|
|
</Match>
|
|
<Match
|
|
when={selectedCodec()?.shortName === "dnxhd"}
|
|
>
|
|
<DNxHDOptions
|
|
codec={selectedCodec()}
|
|
params={ffmpegParams}
|
|
onParamChanged={onParametersChanged}
|
|
/>
|
|
</Match>
|
|
</Switch>
|
|
<div class="row flex-col align-items-center">
|
|
<h3 class="k-form-section-title">Audio</h3>
|
|
</div>
|
|
<form class="k-form">
|
|
<label for="targetCodec">Codec</label>
|
|
<select
|
|
class="k-dropdown"
|
|
id="targetCodec"
|
|
value={audioCodec()}
|
|
oninput={(e) => setAudioCodec(e.target.value)}
|
|
>
|
|
<option value="copy">Copy from source</option>
|
|
<For each={audioCodecList()}>
|
|
{(item, _) => (
|
|
<option value={item.shortName}>
|
|
{item.description}
|
|
</option>
|
|
)}
|
|
</For>
|
|
</select>
|
|
</form>
|
|
<div class="row flex-col align-items-center">
|
|
<h3 class="k-form-section-title">
|
|
Extra Arguments
|
|
</h3>
|
|
</div>
|
|
<form
|
|
class="k-form"
|
|
onsubmit={(e) => e.preventDefault()}
|
|
>
|
|
<label for="globalopts">Global Options</label>
|
|
<input
|
|
type="text"
|
|
name="globalopts"
|
|
id="globalopts"
|
|
value={globalopts()}
|
|
oninput={(e) => {
|
|
ffmpegParams.useropts.global =
|
|
e.target.value;
|
|
setGlobalopts(e.target.value);
|
|
}}
|
|
/>
|
|
<label for="inputopts">Input Options</label>
|
|
<input
|
|
type="text"
|
|
name="inputopts"
|
|
id="inputopts"
|
|
value={inputopts()}
|
|
oninput={(e) => {
|
|
ffmpegParams.useropts.input =
|
|
e.target.value;
|
|
setInputopts(e.target.value);
|
|
}}
|
|
/>
|
|
<label for="outputopts">Output Options</label>
|
|
<input
|
|
type="text"
|
|
name="outputopts"
|
|
id="outputopts"
|
|
value={outputopts()}
|
|
oninput={(e) => {
|
|
ffmpegParams.useropts.output =
|
|
e.target.value;
|
|
setOutputopts(e.target.value);
|
|
}}
|
|
/>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<footer class="k-page-footer row flex-col gap2">
|
|
<div class="row flex-col">
|
|
<label for="outputCommand">Command</label>
|
|
<pre id="outputCommand" class="k-text-field col">
|
|
{outputCommand()}
|
|
</pre>
|
|
</div>
|
|
<div class="row gap2">
|
|
<button class="k-button" onclick={convertAllClicked}>
|
|
Convert All
|
|
</button>
|
|
<button
|
|
class="k-button"
|
|
onclick={convertSelectedClicked}
|
|
disabled={selectedClip() === ""}
|
|
>
|
|
Convert Selected
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default App;
|