Really basic functionality
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 10 2.5 C 9.0680195 2.5 8.2844627 3.1373007 8.0625 4 L 2 4 L 2 5 L 8.0625 5 C 8.2844627 5.8626993 9.0680195 6.5 10 6.5 C 10.931981 6.5 11.715537 5.8626993 11.9375 5 L 14 5 L 14 4 L 11.9375 4 C 11.715537 3.1373007 10.931981 2.5 10 2.5 z M 5 9.5 C 4.0680191 9.5 3.2844626 10.137301 3.0625 11 L 2 11 L 2 12 L 3.0625 12 C 3.2844626 12.862699 4.0680191 13.5 5 13.5 C 5.9319809 13.5 6.7155374 12.862699 6.9375 12 L 7 12 L 9 12 L 14 12 L 14 11 L 9 11 L 7 11 L 6.9375 11 C 6.7155374 10.137301 5.9319809 9.5 5 9.5 z M 5 10.5 C 5.55228 10.5 6 10.94772 6 11.5 C 6 12.05228 5.55228 12.5 5 12.5 C 4.44772 12.5 4 12.05228 4 11.5 C 4 10.94772 4.44772 10.5 5 10.5 z " class="ColorScheme-Text"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 983 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
<g class="ColorScheme-Text" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="m8 2a6 6 0 0 0 -6 6 6 6 0 0 0 6 6 6 6 0 0 0 6-6 6 6 0 0 0 -6-6zm0 1a5 5 0 0 1 5 5 5 5 0 0 1 -5 5 5 5 0 0 1 -5-5 5 5 0 0 1 5-5z"/>
|
||||
<path d="m7 4h2v2h-2z"/>
|
||||
<path d="m7 7h2v5h-2z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
<path d="m2 2v12l12-6z" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m5 2v2h1v-1h4v1h1v-2h-5zm-3 3v1h2v8h8v-8h2v-1zm3 1h6v7h-6z" class="ColorScheme-Text"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 10 2.5 C 9.0680195 2.5 8.2844627 3.1373007 8.0625 4 L 2 4 L 2 5 L 8.0625 5 C 8.2844627 5.8626993 9.0680195 6.5 10 6.5 C 10.931981 6.5 11.715537 5.8626993 11.9375 5 L 14 5 L 14 4 L 11.9375 4 C 11.715537 3.1373007 10.931981 2.5 10 2.5 z M 5 9.5 C 4.0680191 9.5 3.2844626 10.137301 3.0625 11 L 2 11 L 2 12 L 3.0625 12 C 3.2844626 12.862699 4.0680191 13.5 5 13.5 C 5.9319809 13.5 6.7155374 12.862699 6.9375 12 L 7 12 L 9 12 L 14 12 L 14 11 L 9 11 L 7 11 L 6.9375 11 C 6.7155374 10.137301 5.9319809 9.5 5 9.5 z M 5 10.5 C 5.55228 10.5 6 10.94772 6 11.5 C 6 12.05228 5.55228 12.5 5 12.5 C 4.44772 12.5 4 12.05228 4 11.5 C 4 10.94772 4.44772 10.5 5 10.5 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 989 B |
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
<g class="ColorScheme-Text" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="m8 2a6 6 0 0 0 -6 6 6 6 0 0 0 6 6 6 6 0 0 0 6-6 6 6 0 0 0 -6-6zm0 1a5 5 0 0 1 5 5 5 5 0 0 1 -5 5 5 5 0 0 1 -5-5 5 5 0 0 1 5-5z"/>
|
||||
<path d="m7 4h2v2h-2z"/>
|
||||
<path d="m7 7h2v5h-2z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
<path d="m2 2v12l12-6z" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m5 2v2h1v-1h4v1h1v-2h-5zm-3 3v1h2v8h8v-8h2v-1zm3 1h6v7h-6z"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,161 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import type { CodecInfo, FFmpegParams } from "../util/ffmpeg";
|
||||
import HelpAbout from "../assets/breeze/actions/16/help-about.svg";
|
||||
import { os } from "@neutralinojs/lib";
|
||||
|
||||
const information = {
|
||||
h264: {
|
||||
defaultCrf: 23,
|
||||
},
|
||||
hevc: {
|
||||
defaultCrf: 28,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for H.264/H.265 codecs
|
||||
*/
|
||||
function H264Options(props: {
|
||||
codec: CodecInfo | undefined;
|
||||
params: FFmpegParams;
|
||||
onParamChanged: (key: string, value: any) => void;
|
||||
}) {
|
||||
const [twopass, setTwopass] = createSignal(false);
|
||||
|
||||
return (
|
||||
<section id="commonLossyOptions">
|
||||
<div class="row flex-col align-items-center">
|
||||
<h3 class="k-form-section-title">Encoder Options</h3>
|
||||
</div>
|
||||
<div class="k-form">
|
||||
<div></div>
|
||||
<div class="checkbox-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={props.params.twopass?.toString()}
|
||||
onInput={(e) => {
|
||||
props.params.twopass = e.target.checked;
|
||||
props.onParamChanged("twopass", e.target.checked);
|
||||
setTwopass(e.target.checked);
|
||||
}}
|
||||
id="twopassCheck"
|
||||
/>
|
||||
<label for="twopassCheck">
|
||||
Use target bitrate instead of CRF
|
||||
</label>
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={() =>
|
||||
os.open(
|
||||
"https://trac.ffmpeg.org/wiki/Encode/H.264#twopass",
|
||||
)
|
||||
}
|
||||
title="This will use the two-pass rate control mode instead of relying on a Constant Rate Factor (CRF) value."
|
||||
>
|
||||
<img src={HelpAbout} />
|
||||
</button>
|
||||
</div>
|
||||
<label>Preset</label>
|
||||
<select
|
||||
class="k-dropdown"
|
||||
name="encodingPreset"
|
||||
id="encodingPreset"
|
||||
value={props.params.preset ?? "medium"}
|
||||
oninput={(e) => {
|
||||
props.params.preset = e.target.value;
|
||||
props.onParamChanged("preset", e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="ultrafast">ultrafast</option>
|
||||
<option value="superfast">superfast</option>
|
||||
<option value="veryfast">veryfast</option>
|
||||
<option value="faster">faster</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="medium">medium (Default)</option>
|
||||
<option value="slow">slow</option>
|
||||
<option value="slower">slower</option>
|
||||
<option value="veryslow">veryslow</option>
|
||||
<option
|
||||
value="placebo"
|
||||
title="Don't use this option, it rarely helps."
|
||||
>
|
||||
placebo
|
||||
</option>
|
||||
</select>
|
||||
<Show
|
||||
when={twopass()}
|
||||
fallback={
|
||||
<>
|
||||
<label for="crf">CRF</label>
|
||||
<input
|
||||
type="number"
|
||||
name="crf"
|
||||
id="crf"
|
||||
value={props.params.crf ?? "23"}
|
||||
oninput={(e) => {
|
||||
props.params.crf = parseInt(e.target.value);
|
||||
props.onParamChanged(
|
||||
"crf",
|
||||
parseInt(e.target.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label for="bitrate">Bitrate</label>
|
||||
{/* Using 12 Mbps (YouTube's recommended bitrate for high frame rate 1080p video) as an arbitrary value */}
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
name="bitrate"
|
||||
id="bitrate"
|
||||
value={props.params.vbitrate ?? 12000}
|
||||
oninput={(e) => {
|
||||
props.params.vbitrate = parseInt(
|
||||
e.target.value,
|
||||
);
|
||||
props.onParamChanged(
|
||||
"vbitrate",
|
||||
parseInt(e.target.value),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span> Kbps</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.codec?.shortName === "h264"}>
|
||||
<div></div>
|
||||
<div class="checkbox-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={props.params.faststart?.toString()}
|
||||
onInput={(e) => {
|
||||
props.params.faststart = e.target.checked;
|
||||
props.onParamChanged(
|
||||
"faststart",
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
id="fastStartCheck"
|
||||
/>
|
||||
<label for="fastStartCheck">Enable Fast Start</label>
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={() =>
|
||||
os.open(
|
||||
"https://trac.ffmpeg.org/wiki/Encode/H.264#faststartforwebvideo",
|
||||
)
|
||||
}
|
||||
title="This will move some information to the beginning of your file and allow the video to begin playing before it is completely downloaded by the viewer, recommended for web videos. Click for more information."
|
||||
>
|
||||
<img src={HelpAbout} />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default H264Options;
|
||||
@@ -0,0 +1,208 @@
|
||||
/* An attempt of imitating KDE's Kirigami UI Framework */
|
||||
|
||||
:root {
|
||||
--k-grid-unit: 16px;
|
||||
--k-small-spacing: 4px;
|
||||
--k-medium-spacing: 8px;
|
||||
--k-border-radius: 5px;
|
||||
--k-border-color: #b2b4b6;
|
||||
--k-background-color: #eff0f1;
|
||||
--k-secondary-background: #dee0e2;
|
||||
--k-headerbar-unfocused: #eff0f1;
|
||||
--k-primary-highlight: white;
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--k-border-color: #4e5359;
|
||||
--k-headerbar-unfocused: #202326;
|
||||
--k-secondary-background: #292c30;
|
||||
--k-background-color: #202326;
|
||||
--k-primary-highlight: #292c30;
|
||||
}
|
||||
|
||||
body {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.k-dropdown {
|
||||
background-image: url("/breeze-dark/actions/16/go-down.svg") !important;
|
||||
}
|
||||
}
|
||||
|
||||
.k-page-header {
|
||||
background-color: var(--k-secondary-background);
|
||||
border-bottom: 1px solid var(--k-border-color);
|
||||
padding: 6px 0;
|
||||
|
||||
.page-title {
|
||||
padding: 0 var(--k-grid-unit);
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
&.k-rborder .page-title {
|
||||
border-right: 1px solid var(--k-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.k-page-header.window-blur {
|
||||
background-color: var(--k-headerbar-unfocused);
|
||||
}
|
||||
|
||||
ul.k-list-view {
|
||||
list-style: none;
|
||||
padding: var(--k-small-spacing);
|
||||
margin: 0;
|
||||
|
||||
&.bordered {
|
||||
border: 1px solid var(--k-border-color);
|
||||
border-radius: var(--k-border-radius);
|
||||
}
|
||||
|
||||
li {
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--k-border-radius);
|
||||
padding: 0 var(--k-small-spacing);
|
||||
}
|
||||
|
||||
li:hover {
|
||||
border-color: var(--system-accent-color);
|
||||
background-color: var(--system-lighter-accent);
|
||||
}
|
||||
|
||||
li.selected {
|
||||
background-color: var(--system-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.k-white-sidebar {
|
||||
background-color: var(--k-primary-highlight);
|
||||
min-width: 32.5vw;
|
||||
|
||||
&.k-rborder {
|
||||
border-right: 1px solid var(--k-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.k-button {
|
||||
border: 1px solid var(--k-border-color);
|
||||
border-radius: var(--k-border-radius);
|
||||
box-shadow: var(--k-border-color) 0 1px;
|
||||
background-color: var(--k-primary-highlight);
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--system-accent-color);
|
||||
}
|
||||
|
||||
&.k-form-button {
|
||||
max-width: 16em;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--system-lighter-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.k-dropdown {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--k-primary-highlight);
|
||||
background-image: url("/breeze/actions/16/go-down.svg");
|
||||
background-size: 12px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--k-medium-spacing) center;
|
||||
padding: var(--k-small-spacing) calc(var(--k-medium-spacing) + 12px)
|
||||
var(--k-small-spacing) var(--k-medium-spacing);
|
||||
border: 1px solid var(--k-border-color);
|
||||
box-shadow: var(--k-border-color) 0 1px;
|
||||
max-width: 16em;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--system-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background-color: var(--k-primary-highlight);
|
||||
border: 1px solid var(--k-border-color);
|
||||
border-radius: var(--k-border-radius);
|
||||
box-shadow: var(--k-border-color) 0 1px;
|
||||
padding: var(--k-small-spacing) var(--k-medium-spacing);
|
||||
max-width: 14.85em;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--system-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
background-color: var(--k-primary-highlight);
|
||||
border: 1px solid var(--k-border-color);
|
||||
border-radius: var(--k-border-radius);
|
||||
box-shadow: var(--k-border-color) 0 1px;
|
||||
padding: var(--k-small-spacing) var(--k-medium-spacing);
|
||||
max-width: 14.85em;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--system-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.k-form {
|
||||
display: grid;
|
||||
gap: var(--k-medium-spacing);
|
||||
grid-template-columns: 40% 60%;
|
||||
|
||||
& > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
||||
.k-form-section-title {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--k-border-color);
|
||||
width: fit-content;
|
||||
margin-bottom: var(--k-medium-spacing);
|
||||
}
|
||||
|
||||
.k-page-footer {
|
||||
padding: var(--k-medium-spacing);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: var(--k-border-radius);
|
||||
}
|
||||
|
||||
.k-text-field {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
background-color: var(--k-primary-highlight);
|
||||
border: 1px solid var(--k-border-color);
|
||||
padding: var(--k-small-spacing);
|
||||
border-radius: var(--k-border-radius);
|
||||
margin: 0;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.k-text-field:hover {
|
||||
border-color: var(--system-accent-color);
|
||||
}
|
||||
|
||||
.p-medium {
|
||||
padding: var(--k-medium-spacing);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
--system-accent-color: accentcolor;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
background-color: var(--k-background-color);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root,
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.justify-content-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gap2 {
|
||||
gap: var(--k-medium-spacing);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.25em 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.25em 0;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:disabled img {
|
||||
filter: invert(50%);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
gap: var(--k-medium-spacing);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App.tsx";
|
||||
import "./css/index.css";
|
||||
import "./css/Kirigami.css";
|
||||
import Neutralino from "@neutralinojs/lib";
|
||||
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";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
Neutralino.init();
|
||||
|
||||
if (window.NL_OS === "Linux") {
|
||||
let accentColorResult = await Neutralino.os.execCommand(
|
||||
`busctl --user call org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop org.freedesktop.portal.Settings ReadOne ss "org.freedesktop.appearance" "accent-color"`,
|
||||
);
|
||||
|
||||
let accentColor = accentColorResult.stdOut
|
||||
.substring(8)
|
||||
.split(" ", 3)
|
||||
.map((v) => Math.round(parseFloat(v) * 255)) as RGB;
|
||||
|
||||
let accentHSV = convert.rgb.hsl(accentColor);
|
||||
|
||||
let lighterAccent = accentHSV;
|
||||
lighterAccent[2] = Math.round(clamp(lighterAccent[2] * 1.2, 0, 100));
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--system-accent-color",
|
||||
`rgb(${accentColor[0]}, ${accentColor[1]}, ${accentColor[2]})`,
|
||||
);
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--system-lighter-accent",
|
||||
`hsl(${lighterAccent[0]} ${lighterAccent[1]} ${lighterAccent[2]})`,
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/" component={App} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
</Router>
|
||||
),
|
||||
root!,
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { events } from "@neutralinojs/lib";
|
||||
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
||||
|
||||
function Settings() {
|
||||
const [windowFocused, setWindowFocused] = createSignal(true);
|
||||
const [useSystemFFmpeg, setUseSystemFFmpeg] = createSignal(true);
|
||||
const [useFFplay, setUseFFplay] = createSignal(true);
|
||||
const [ffmpegPath, setFfmpegPath] = createSignal("");
|
||||
|
||||
function windowIsFocused() {
|
||||
setWindowFocused(false);
|
||||
}
|
||||
|
||||
function windowUnfocused() {
|
||||
setWindowFocused(true);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
events.on("windowFocus", windowIsFocused);
|
||||
events.on("windowBlur", windowUnfocused);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
events.off("windowFocus", windowIsFocused);
|
||||
events.off("windowBlur", windowUnfocused);
|
||||
});
|
||||
|
||||
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">
|
||||
Settings
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-medium col">
|
||||
<div class="row flex-col align-items-center">
|
||||
<h2 class="k-form-section-title">FFmpeg</h2>
|
||||
</div>
|
||||
<div class="k-form">
|
||||
<div></div>
|
||||
<div class="checkbox-container">
|
||||
<input
|
||||
id="useFFplay"
|
||||
type="checkbox"
|
||||
value={useFFplay().toString()}
|
||||
onInput={(e) =>
|
||||
setUseFFplay(e.currentTarget.checked)
|
||||
}
|
||||
checked
|
||||
/>
|
||||
<label for="useFFplay">
|
||||
Use <code>ffplay</code> instead of system's
|
||||
default media player
|
||||
</label>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="checkbox-container">
|
||||
<input
|
||||
id="useSystemFFmpeg"
|
||||
type="checkbox"
|
||||
value={useSystemFFmpeg().toString()}
|
||||
onInput={(e) =>
|
||||
setUseSystemFFmpeg(e.currentTarget.checked)
|
||||
}
|
||||
checked
|
||||
/>
|
||||
<label for="useSystemFFmpeg">
|
||||
Use system's FFmpeg installation
|
||||
</label>
|
||||
</div>
|
||||
<Show when={!useSystemFFmpeg()}>
|
||||
<label for="ffmpegPath">FFmpeg Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ffmpegPath()}
|
||||
onInput={(e) =>
|
||||
setFfmpegPath(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
<div></div>
|
||||
<button class="k-button k-form-button">
|
||||
Download
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="p-medium">
|
||||
<button class="k-button">Save Changes</button>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,129 @@
|
||||
import Neutralino from "@neutralinojs/lib";
|
||||
|
||||
export interface CodecInfo {
|
||||
flags: string;
|
||||
shortName: string;
|
||||
description: string;
|
||||
encoders: string[];
|
||||
}
|
||||
|
||||
export async function getAvailableCodecs(): Promise<CodecInfo[]> {
|
||||
const seperator = "-------";
|
||||
const videoEncodingSupported = /.EV.../;
|
||||
const wideFormattingSpaces = / {2,}/;
|
||||
const decodeEncodeSpecification = / \(((decoders)|(encoders)):.+\)/g;
|
||||
const result = await Neutralino.os.execCommand("ffmpeg -codecs");
|
||||
const rawCodecList = result.stdOut
|
||||
.substring(result.stdOut.indexOf(seperator) + seperator.length)
|
||||
.split("\n");
|
||||
let codecs = [];
|
||||
|
||||
for (let codec of rawCodecList) {
|
||||
codec = codec.trim();
|
||||
const flags = codec.substring(0, 6);
|
||||
|
||||
if (!videoEncodingSupported.test(flags)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameAndDescription = codec
|
||||
.substring(7)
|
||||
.replace(wideFormattingSpaces, " ");
|
||||
|
||||
const seperatorIndex = nameAndDescription.indexOf(" ");
|
||||
const shortName = nameAndDescription.substring(0, seperatorIndex);
|
||||
const description = nameAndDescription
|
||||
.substring(seperatorIndex + 1)
|
||||
.replaceAll(decodeEncodeSpecification, "");
|
||||
|
||||
const encoderIndex = nameAndDescription.search(/ \((encoders):.+\)/);
|
||||
let encoders: string[] = [];
|
||||
|
||||
if (encoderIndex !== -1) {
|
||||
const rawEncoderList = nameAndDescription
|
||||
.substring(encoderIndex)
|
||||
.trim();
|
||||
encoders = rawEncoderList
|
||||
.substring(11, rawEncoderList.length - 1)
|
||||
.split(" ");
|
||||
}
|
||||
|
||||
codecs.push({
|
||||
flags,
|
||||
shortName,
|
||||
description,
|
||||
encoders,
|
||||
});
|
||||
}
|
||||
|
||||
return codecs;
|
||||
}
|
||||
|
||||
export function playFile(path: string) {
|
||||
Neutralino.os.execCommand(`ffplay "${path}"`);
|
||||
}
|
||||
|
||||
export const videoFileExtensions: { [key: string]: string } = {
|
||||
dnxhd: "mov",
|
||||
h264: "mp4",
|
||||
hevc: "mp4",
|
||||
av1: "webm",
|
||||
vp8: "webm",
|
||||
vp9: "webm",
|
||||
};
|
||||
|
||||
export interface FFmpegParams {
|
||||
inputFile?: string;
|
||||
outputFile?: string;
|
||||
vcodec: string;
|
||||
encoder?: string;
|
||||
acodec?: string;
|
||||
crf?: number;
|
||||
twopass?: boolean;
|
||||
/**
|
||||
* Video Bitrate
|
||||
*/
|
||||
vbitrate?: number;
|
||||
/**
|
||||
* Audio Bitrate
|
||||
*/
|
||||
abitrate?: number;
|
||||
hwaccel?: string;
|
||||
preset?: string;
|
||||
faststart?: boolean;
|
||||
doNotUseAn?: boolean;
|
||||
}
|
||||
|
||||
const NULL_LOCATION = window.NL_OS === "Windows" ? "NUL" : "/dev/null";
|
||||
|
||||
export function generateOutputCommand(params: FFmpegParams) {
|
||||
let faststart =
|
||||
params.faststart && params.vcodec === "h264"
|
||||
? " -movflags +faststart"
|
||||
: "";
|
||||
|
||||
if (params.twopass) {
|
||||
const commonOpts = `-i "${params.inputFile ?? "{fileName}"}" -c:v ${params.encoder ?? params.vcodec} -b:v ${
|
||||
params.vbitrate ?? 12000
|
||||
}k${faststart}${
|
||||
params.preset === undefined ? "" : ` -preset ${params.preset}`
|
||||
} -progress -`;
|
||||
|
||||
return `ffmpeg -hwaccel auto -y ${commonOpts} ${params.vcodec === "h264" ? "-pass 1" : "-x265-params pass=1"} ${
|
||||
params.doNotUseAn ? "-vsync cfr" : "-an"
|
||||
} -f null ${NULL_LOCATION} &&
|
||||
ffmpeg -y -hwaccel auto ${commonOpts} ${
|
||||
params.vcodec === "h264" ? "-pass 2" : "-x265-params pass=2"
|
||||
} -c:a ${
|
||||
params.acodec ?? "copy"
|
||||
}${params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k`} "${params.outputFile ?? "{output}"}"`;
|
||||
}
|
||||
|
||||
return `ffmpeg -y -hwaccel auto -i "${params.inputFile ?? "{fileName}"}" -c:v ${params.encoder ?? params.vcodec}${
|
||||
params.crf === undefined ? "" : ` -crf ${params.crf}`
|
||||
}${faststart}${
|
||||
params.preset === undefined ? "" : ` -preset ${params.preset}`
|
||||
} -c:a ${params.acodec ?? "copy"}${
|
||||
params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k`
|
||||
} -progress - "${params.outputFile ?? "{output}"}"`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||