9 Commits

Author SHA1 Message Date
linesofcodes f1da312b95 Make app more Linux package manager friendly
Build / build (push) Successful in 1m39s
2025-09-23 18:36:35 +07:00
linesofcodes 37568aa0d1 Pixel Format & Audio Encoder fix
Build / build (push) Successful in 1m14s
2025-09-22 00:24:52 +07:00
linesofcodes 466a7cedca Audio Encoders
Build / build (push) Successful in 1m27s
2025-09-21 16:00:38 +07:00
linesofcodes 77e91fde1c Updated Neutralino.js 2025-09-21 12:05:51 +07:00
linesofcodes 74cebbf180 Audio Codecs & Slightly Improve UX
Build / build (push) Successful in 1m28s
2025-09-21 10:56:22 +07:00
linesofcodes 68d919ab1e Update packages & vp9_qsv
Build / build (push) Successful in 1m28s
2025-09-15 12:38:26 +07:00
linesofcodes d586a8f222 Fix output builds
Build / build (push) Successful in 1m26s
2025-08-26 20:08:04 +07:00
linesofcodes 4cad43abe1 DNxHD support, Better screen reader support
Build / build (push) Successful in 1m12s
2025-08-19 13:02:39 +07:00
linesofcodes 3921c003bb Allows defining custom arguments 2025-08-19 10:47:08 +07:00
17 changed files with 1000 additions and 641 deletions
+2 -2
View File
@@ -29,8 +29,8 @@ jobs:
- name: Package application - name: Package application
run: | run: |
cd ${{ github.workspace }} cd ${{ github.workspace }}
wget https://staticlines.dailitation.xyz/neutralinojs-v6.2.0.zip wget https://staticlines.dailitation.xyz/neutralinojs-v6.3.0.zip
unzip neutralinojs-v6.2.0.zip -d bin/ unzip neutralinojs-v6.3.0.zip -d bin/
pnpx @neutralinojs/neu build pnpx @neutralinojs/neu build
- name: Upload artifacts - name: Upload artifacts
uses: ChristopherHX/gitea-upload-artifact@v4 uses: ChristopherHX/gitea-upload-artifact@v4
+10
View File
@@ -0,0 +1,10 @@
frontend:
cd solid-src; \
pnpm build
build: frontend
neu build
release: frontend
neu build --clean -r --embed-resources
+3 -6
View File
@@ -5,10 +5,7 @@ A tool to interactively (re-)encode videos using FFmpeg.
Uses Neutralino.js and Solid.js. Uses Neutralino.js and Solid.js.
This app _tries_ to imitate KDE's Kirigami UI framework, and also makes use of This app _tries_ to imitate KDE's Kirigami UI framework, and also makes use of
Breeze icons Breeze icons (Located in `./solid-src/public/breeze[-dark]`)
- `./solid-src/src/assets/breeze[-dark]`: Icons used by TSX files
- `./solid-src/public/breeze[-dark]`: Icons used by CSS files
Vencoder is tested with FFmpeg 7.1.1, should be compatible with older versions Vencoder is tested with FFmpeg 7.1.1, should be compatible with older versions
but is not guaranteed. but is not guaranteed.
@@ -56,7 +53,7 @@ encoders supported by your FFmpeg install will show up.
- [ ] av1_nvenc - [ ] av1_nvenc
- [ ] av1_qsv - [ ] av1_qsv
- [ ] av1_vaapi - [ ] av1_vaapi
- [ ] DNxHD - [x] DNxHD (Does not provide options to deal with its pickiness yet)
- [ ] H.264 - [ ] H.264
- [x] libx264 - [x] libx264
- [x] libx264rgb (Untested, but _should_ work) - [x] libx264rgb (Untested, but _should_ work)
@@ -81,7 +78,7 @@ encoders supported by your FFmpeg install will show up.
- [ ] VP9 - [ ] VP9
- [ ] libvpx-vp9 - [ ] libvpx-vp9
- [ ] vp9_vaapi - [ ] vp9_vaapi
- [ ] vp9_qsv - [x] vp9_qsv (Really Basic)
## Gitea Actions ## Gitea Actions
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env xdg-open
[Desktop Entry]
Type=Application
Version=1.5
Name=Vencoder
Exec=/usr/bin/vencoder
Terminal=false
+12 -6
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/neutralinojs/neutralinojs/main/schemas/neutralino.config.schema.json", "$schema": "https://raw.githubusercontent.com/neutralinojs/neutralinojs/main/schemas/neutralino.config.schema.json",
"applicationId": "xyz.dailitation.linesofcodes.vencoder", "applicationId": "xyz.dailitation.linesofcodes.vencoder",
"version": "1.0.0", "version": "0.1.1",
"defaultMode": "window", "defaultMode": "window",
"documentRoot": "/solid-src/dist/", "documentRoot": "/solid-src/dist/",
"url": "/", "url": "/",
@@ -17,23 +17,29 @@
"storage.*", "storage.*",
"debug.log" "debug.log"
], ],
"dataLocation": "system",
"storageLocation": "system",
"logging": {
"writeToLogFile": false
},
"modes": { "modes": {
"window": { "window": {
"title": "Vencoder", "title": "Vencoder",
"width": 800, "width": 1280,
"height": 600, "height": 720,
"minWidth": 600, "minWidth": 600,
"minHeight": 400, "minHeight": 400,
"icon": "/solid-src/public/vite.svg", "icon": "/solid-src/public/vite.svg",
"enableInspector": true "enableInspector": true,
"openInspectorOnStartup": false
} }
}, },
"cli": { "cli": {
"binaryName": "vencoder", "binaryName": "vencoder",
"resourcesPath": "/solid-src/dist/", "resourcesPath": "/solid-src/dist/",
"extensionsPath": "/extensions/", "extensionsPath": "/extensions/",
"binaryVersion": "6.2.0", "binaryVersion": "6.3.0",
"clientVersion": "6.2.0", "clientVersion": "6.3.0",
"frontendLibrary": { "frontendLibrary": {
"patchFile": "/solid-src/index.html", "patchFile": "/solid-src/index.html",
"devUrl": "http://localhost:5173" "devUrl": "http://localhost:5173"
+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:35731/__neutralino_globals.js"></script> <script src="/__neutralino_globals.js"></script>
<title>Vencoder</title> <title>Vencoder</title>
</head> </head>
+5 -5
View File
@@ -9,17 +9,17 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@neutralinojs/lib": "^6.2.0", "@neutralinojs/lib": "^6.3.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"color-convert": "^3.1.0", "color-convert": "^3.1.2",
"solid-js": "^1.9.9" "solid-js": "^1.9.9"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.3.0", "@types/node": "^24.5.2",
"prettier": "3.6.2", "prettier": "3.6.2",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.1.2", "vite": "^7.1.7",
"vite-plugin-solid": "^2.11.8" "vite-plugin-solid": "^2.11.8"
}, },
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748" "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
} }
+357 -330
View File
File diff suppressed because it is too large Load Diff
+346 -230
View File
@@ -14,24 +14,27 @@ import {
generateOutputCommand, generateOutputCommand,
getAvailableCodecs, getAvailableCodecs,
getLengthMicroseconds, getLengthMicroseconds,
getPixelFormats,
playFile, playFile,
videoFileExtensions, videoFileExtensions,
type CodecInfo, type CodecInfo,
type CodecList,
type FFmpegParams, type FFmpegParams,
} from "./util/ffmpeg"; } from "./util/ffmpeg";
import Neutralino from "@neutralinojs/lib"; import Neutralino from "@neutralinojs/lib";
import H264Options from "./components/H264Options"; import H264Options from "./components/H264Options";
import { openFile } from "./util/oshelper"; import { openFile } from "./util/oshelper";
import { getTemporaryFilePath } from "./util/path"; import { getTemporaryFilePath, getVencoderFolder } from "./util/path";
import { generateRandomString } from "./util/string"; import { generateRandomString } from "./util/string";
import "./css/icons.css"; import "./css/icons.css";
import BreezeIcon from "./components/BreezeIcon"; import BreezeIcon from "./components/BreezeIcon";
import AV1Options from "./components/AV1Options"; import AV1Options from "./components/AV1Options";
import DNxHDOptions from "./components/DNxHDOptions";
const commonCodecs = new Set(["h264", "hevc", "vp8", "vp9", "av1", "dnxhd"]); const commonCodecs = new Set(["h264", "hevc", "vp8", "vp9", "av1", "dnxhd"]);
interface RunningProcessInfo { interface RunningProcessInfo {
process: Neutralino.os.SpawnedProcess; process: Neutralino.SpawnedProcess;
file: string; file: string;
length: number; length: number;
} }
@@ -40,6 +43,7 @@ function App() {
const [windowFocused, setWindowFocused] = createSignal(true); const [windowFocused, setWindowFocused] = createSignal(true);
const [displayedCodecs, setDisplayedCodecs]: Signal<CodecInfo[]> = const [displayedCodecs, setDisplayedCodecs]: Signal<CodecInfo[]> =
createSignal([] as CodecInfo[]); createSignal([] as CodecInfo[]);
const [audioCodecList, setAudioCodecList] = createSignal([] as CodecInfo[]);
const [fileList, setFileList] = createSignal([] as string[]); const [fileList, setFileList] = createSignal([] as string[]);
const [selectedClip, setSelectedClip] = createSignal(""); const [selectedClip, setSelectedClip] = createSignal("");
const [outputCommand, setOutputCommand] = createSignal( const [outputCommand, setOutputCommand] = createSignal(
@@ -52,9 +56,23 @@ function App() {
RunningProcessInfo[] RunningProcessInfo[]
>([]); >([]);
const [customFileExt, setCustomFileExt] = createSignal(""); const [customFileExt, setCustomFileExt] = createSignal("");
const [globalopts, setGlobalopts] = createSignal("");
const [inputopts, setInputopts] = createSignal("");
const [outputopts, setOutputopts] = createSignal("");
const [audioCodec, setAudioCodec] = createSignal("copy");
const [audioEncoder, setAudioEncoder] = createSignal("");
const [pixelFormatList, setPixelFormatList] = createSignal([] as string[]);
const [pixelFormat, setPixelFormat] = createSignal("");
const logs: { [id: number]: string[] } = {}; const logs: { [id: number]: string[] } = {};
let supportedCodecs: CodecInfo[] = []; let supportedCodecs: CodecList = { vcodecs: [], acodecs: [] };
let ffmpegParams: FFmpegParams = { vcodec: "" }; let ffmpegParams: FFmpegParams = {
vcodec: "",
useropts: {
global: "",
input: "",
output: "",
},
};
let successfulCount = 0; let successfulCount = 0;
let unsuccessfulCount = 0; let unsuccessfulCount = 0;
let totalCount = 0; let totalCount = 0;
@@ -116,6 +134,7 @@ function App() {
supportedCodecs = await getAvailableCodecs(); supportedCodecs = await getAvailableCodecs();
filterDisplayedCodecs(); filterDisplayedCodecs();
setAudioCodecList(supportedCodecs.acodecs);
const firstCodec = displayedCodecs()[0]; const firstCodec = displayedCodecs()[0];
@@ -123,6 +142,8 @@ function App() {
ffmpegParams.encoder = firstCodec.encoders[0]; ffmpegParams.encoder = firstCodec.encoders[0];
setSelectedCodec(firstCodec); setSelectedCodec(firstCodec);
setSelectedEncoder(firstCodec.encoders[0]); setSelectedEncoder(firstCodec.encoders[0]);
setPixelFormatList(await getPixelFormats());
}); });
onCleanup(() => { onCleanup(() => {
@@ -169,12 +190,14 @@ function App() {
function filterDisplayedCodecs() { function filterDisplayedCodecs() {
if (showCommonCodecs()) { if (showCommonCodecs()) {
setDisplayedCodecs( setDisplayedCodecs(
supportedCodecs.filter((v) => commonCodecs.has(v.shortName)), supportedCodecs.vcodecs.filter((v) =>
commonCodecs.has(v.shortName),
),
); );
return; return;
} }
setDisplayedCodecs(supportedCodecs); setDisplayedCodecs(supportedCodecs.vcodecs);
} }
function showCommonCodecsChanged(e: InputEvent) { function showCommonCodecsChanged(e: InputEvent) {
@@ -195,6 +218,11 @@ function App() {
ffmpegParams = { ffmpegParams = {
vcodec: codecObj?.shortName ?? "", vcodec: codecObj?.shortName ?? "",
useropts: {
global: "",
input: "",
output: "",
},
}; };
let encoder = newValue; let encoder = newValue;
@@ -206,6 +234,23 @@ function App() {
setSelectedEncoder(encoder); setSelectedEncoder(encoder);
} }
function getAudioEncoders() {
const codec = audioCodec();
let encoders = audioCodecList().find(
(v) => v.shortName === codec,
)?.encoders;
if (encoders) {
setAudioEncoder(encoders[0]);
}
if (encoders instanceof Array && encoders.length === 0) {
encoders = undefined;
}
return encoders;
}
function onParametersChanged(key: string, value: any) { function onParametersChanged(key: string, value: any) {
// @ts-ignore // @ts-ignore
ffmpegParams[key] = value; ffmpegParams[key] = value;
@@ -229,10 +274,18 @@ function App() {
encoder = undefined; encoder = undefined;
} }
let acodec = audioEncoder();
if (acodec === "") {
acodec = audioCodec();
}
const pixFmt = pixelFormat();
ffmpegParams = { ffmpegParams = {
vcodec: selectedCodec()?.shortName ?? "", vcodec: selectedCodec()?.shortName ?? "",
encoder, encoder,
acodec: ffmpegParams.acodec, acodec,
abitrate: ffmpegParams.abitrate, abitrate: ffmpegParams.abitrate,
crf: ffmpegParams.crf, crf: ffmpegParams.crf,
doNotUseAn: ffmpegParams.doNotUseAn, doNotUseAn: ffmpegParams.doNotUseAn,
@@ -242,6 +295,12 @@ function App() {
preset: ffmpegParams.preset, preset: ffmpegParams.preset,
twopass: ffmpegParams.twopass, twopass: ffmpegParams.twopass,
vbitrate: ffmpegParams.vbitrate, vbitrate: ffmpegParams.vbitrate,
useropts: {
global: globalopts(),
input: inputopts(),
output: outputopts(),
},
pixelFormat: pixFmt === "" ? undefined : pixFmt,
}; };
setOutputCommand(generateOutputCommand(ffmpegParams)); setOutputCommand(generateOutputCommand(ffmpegParams));
@@ -261,14 +320,7 @@ function App() {
? videoFileExtensions[selectedCodec()?.shortName ?? ""] ? videoFileExtensions[selectedCodec()?.shortName ?? ""]
: customExt; : customExt;
switch (window.NL_OS) { ffmpegParams.outputFile = `${await getVencoderFolder()}${fileName}.${fileExt}`;
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 = ( const outputDir = (
await Neutralino.filesystem.getPathParts( await Neutralino.filesystem.getPathParts(
@@ -287,8 +339,8 @@ function App() {
const userAnswer = await Neutralino.os.showMessageBox( const userAnswer = await Neutralino.os.showMessageBox(
"File already exists", "File already exists",
`A file at ${ffmpegParams.outputFile} already exists. Would you like to overwrite it?`, `A file at ${ffmpegParams.outputFile} already exists. Would you like to overwrite it?`,
Neutralino.os.MessageBoxChoice.YES_NO, Neutralino.MessageBoxChoice.YES_NO,
Neutralino.os.Icon.QUESTION, Neutralino.Icon.QUESTION,
); );
if (userAnswer === "NO") { if (userAnswer === "NO") {
@@ -380,225 +432,289 @@ function App() {
} }
return ( return (
<main class="row flex-col"> <main class="row">
<div class="container" style={{ flex: "1" }}> <div class="row flex-col h-full">
<div class="row h-full"> <header
<div class="row flex-col h-full"> class={`k-page-header k-rborder ${windowFocused() ? "" : "window-blur"}`}
<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"
> >
<div class="page-title">Vencoder</div> <BreezeIcon
</header> icon="b b-trash-empty"
<div alt="Remove Selected Video"
class="row flex-col gap2 k-white-sidebar k-rborder h-full" />
style={{ padding: "8px" }} </button>
<button
disabled={selectedClip() === ""}
onclick={playBtnClicked}
class="icon-button k-button"
> >
<ul class="k-list-view bordered col"> <BreezeIcon
<For each={fileList()}> icon="playback-start"
{(item, _) => ( alt="Preview Selected Video"
<li />
class={ </button>
item == selectedClip() <button
? "selected" class="icon-button k-button"
: "" onclick={settingsBtnPressed}
}
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> <BreezeIcon icon="configure" alt="Configure" />
</header> </button>
<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>
<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>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>
</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> </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>
<label for="pixelFormat">Pixel Format</label>
<select
name="pixelFormat"
id="pixelFormat"
class="k-dropdown"
title="This option is here for the people who knows what they're doing. Not all encoders will support every pixel format."
value={pixelFormat()}
oninput={(e) => setPixelFormat(e.target.value)}
>
<option value="">Same as source</option>
<For each={pixelFormatList()}>
{(item, _) => (
<option value={item}>{item}</option>
)}
</For>
</select>
</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="audioCodec">Codec</label>
<select
class="k-dropdown"
id="audioCodec"
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>
<Show when={getAudioEncoders()}>
<label for="audioEncoder">Encoder</label>
<select
class="k-dropdown"
id="audioEncoder"
value={audioEncoder()}
oninput={(e) =>
setAudioEncoder(e.target.value)
}
>
<For each={getAudioEncoders()}>
{(item, _) => (
<option value={item}>{item}</option>
)}
</For>
</select>
</Show>
</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> </main>
); );
} }
+55
View File
@@ -0,0 +1,55 @@
import { os } from "@neutralinojs/lib";
import { type CodecInfo, type FFmpegParams } from "../util/ffmpeg";
import BreezeIcon from "./BreezeIcon";
/**
* Options for H.264/H.265 codecs
*/
function DNxHDOptions(props: {
codec: CodecInfo | undefined;
params: FFmpegParams;
onParamChanged: (key: string, value: any) => void;
}) {
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">
<label>Help</label>
<div>
<button
class="icon-button"
onclick={() =>
os.open("https://askubuntu.com/a/907515")
}
title="DNxHD is a picky encoder."
>
<BreezeIcon icon="help-about" alt="Help" />
</button>
</div>
<label for="profile">Profile</label>
<select
class="k-dropdown"
name="profile"
id="profile"
value={props.params.outputopts?.profile ?? "dnxhd"}
oninput={(e) => {
props.onParamChanged("outputopts", {
profile: e.target.value,
});
}}
>
<option value="dnxhd">DNxHD</option>
<option value="dnxhr_444">DNxHR 444</option>
<option value="dnxhr_hqx">DNxHR HQX</option>
<option value="dnxhr_hq">DNxHR HQ</option>
<option value="dnxhr_sq">DNxHR SQ</option>
<option value="dnxhr_lb">DNxHR LB</option>
</select>
</div>
</section>
);
}
export default DNxHDOptions;
+1 -1
View File
@@ -63,7 +63,7 @@ function H264Options(props: {
<BreezeIcon icon="help-about" alt="Help" /> <BreezeIcon icon="help-about" alt="Help" />
</button> </button>
</div> </div>
<label>Preset</label> <label for="encodingPreset">Preset</label>
<select <select
class="k-dropdown" class="k-dropdown"
name="encodingPreset" name="encodingPreset"
+7 -4
View File
@@ -64,10 +64,12 @@ function LibaomOptions(props: {
<BreezeIcon icon="help-about" alt="Help" /> <BreezeIcon icon="help-about" alt="Help" />
</button> </button>
</div> </div>
<label>Rate-control modes</label> <label for="rateControlMode">Rate-control modes</label>
<select <select
class="k-dropdown" class="k-dropdown"
onchange={(e) => setRateControlMode(e.target.value)} onchange={(e) => setRateControlMode(e.target.value)}
name="rateControlMode"
id="rateControlMode"
> >
<option value="Constant">Constant Quality</option> <option value="Constant">Constant Quality</option>
<option value="Constrained">Constrained Quality</option> <option value="Constrained">Constrained Quality</option>
@@ -80,7 +82,7 @@ function LibaomOptions(props: {
rateControlMode() === "Constrained" rateControlMode() === "Constrained"
} }
> >
<label>CRF</label> <label for="crf">CRF</label>
<input <input
type="number" type="number"
name="crf" name="crf"
@@ -97,12 +99,13 @@ function LibaomOptions(props: {
/> />
</Show> </Show>
<Show when={rateControlMode() !== "Constant"}> <Show when={rateControlMode() !== "Constant"}>
<label>Bitrate</label> <label for="bitrate">Bitrate</label>
<div class="row gap2"> <div class="row gap2 align-items-center">
<input <input
type="number" type="number"
name="bitrate" name="bitrate"
id="bitrate" id="bitrate"
aria-label="Kbps"
value={props.params.vbitrate ?? DEFAULT_BITRATE} value={props.params.vbitrate ?? DEFAULT_BITRATE}
oninput={(e) => { oninput={(e) => {
props.onParamChanged( props.onParamChanged(
+22 -2
View File
@@ -1,4 +1,8 @@
import { type CodecInfo, type FFmpegParams } from "@/util/ffmpeg"; import {
DEFAULT_BITRATE,
type CodecInfo,
type FFmpegParams,
} from "@/util/ffmpeg";
import { os } from "@neutralinojs/lib"; import { os } from "@neutralinojs/lib";
import BreezeIcon from "@/components/BreezeIcon"; import BreezeIcon from "@/components/BreezeIcon";
import { onMount } from "solid-js"; import { onMount } from "solid-js";
@@ -10,7 +14,10 @@ function Librav1eOptions(props: {
}) { }) {
onMount(() => { onMount(() => {
props.onParamChanged("crf", undefined); props.onParamChanged("crf", undefined);
props.onParamChanged("vbitrate", undefined); props.onParamChanged(
"vbitrate",
props.params.vbitrate ?? DEFAULT_BITRATE,
);
props.onParamChanged("speed", 5); props.onParamChanged("speed", 5);
}); });
@@ -46,6 +53,19 @@ function Librav1eOptions(props: {
props.onParamChanged("speed", e.target.value) props.onParamChanged("speed", e.target.value)
} }
/> />
<label for="bitrate">Bitrate</label>
<div class="row gap2 align-items-center">
<input
type="number"
name="bitrate"
id="bitrate"
value={props.params.vbitrate ?? DEFAULT_BITRATE}
oninput={(e) =>
props.onParamChanged("vbitrate", e.target.value)
}
/>
<span>Kbps</span>
</div>
</div> </div>
</section> </section>
); );
+10
View File
@@ -92,3 +92,13 @@ h2 {
gap: var(--k-medium-spacing); gap: var(--k-medium-spacing);
align-items: center; align-items: center;
} }
.page-content {
display: flex;
flex-direction: column;
flex: 1;
padding: var(--k-grid-unit) var(--k-small-spacing);
overflow-x: hidden;
overflow-y: auto;
max-height: 90vh;
}
+27 -14
View File
@@ -1,4 +1,5 @@
import { events, os, storage } from "@neutralinojs/lib"; import { getVencoderFolder } from "@/util/path";
import { events, os, storage, type SpawnedProcess } from "@neutralinojs/lib";
import { createSignal, onMount, onCleanup, Show, Index } from "solid-js"; import { createSignal, onMount, onCleanup, Show, Index } from "solid-js";
interface TargetFile { interface TargetFile {
@@ -29,7 +30,7 @@ interface FFmpegProgressInfo {
function ProgressPage() { function ProgressPage() {
const [windowFocused, setWindowFocused] = createSignal(true); const [windowFocused, setWindowFocused] = createSignal(true);
const [runningProcesses, setRunningProcesses] = createSignal< const [runningProcesses, setRunningProcesses] = createSignal<
os.SpawnedProcess[] SpawnedProcess[]
>([]); >([]);
const [finished, setFinished] = createSignal(false); const [finished, setFinished] = createSignal(false);
const [fileInfo, setFileInfo] = createSignal<TargetFile[]>([]); const [fileInfo, setFileInfo] = createSignal<TargetFile[]>([]);
@@ -131,6 +132,14 @@ function ProgressPage() {
setFinished(true); setFinished(true);
} }
async function openFolder() {
const folder = await getVencoderFolder();
if (folder) {
os.open(folder);
}
}
return ( return (
<main class="row flex-col"> <main class="row flex-col">
<div class="container row flex-col" style={{ flex: "1" }}> <div class="container row flex-col" style={{ flex: "1" }}>
@@ -182,20 +191,24 @@ function ProgressPage() {
)} )}
</Index> </Index>
</div> </div>
<Show when={!finished()}> <footer class="p-medium row" style={{ "align-items": "end" }}>
<footer <Show
class="p-medium row" when={finished()}
style={{ "align-items": "end" }} fallback={
<button
class="k-button"
disabled={isCancelling()}
onclick={cancelBtnClicked}
>
Cancel
</button>
}
> >
<button <button class="k-button" onclick={openFolder}>
class="k-button" Open Folder
disabled={isCancelling()}
onclick={cancelBtnClicked}
>
Cancel
</button> </button>
</footer> </Show>
</Show> </footer>
</div> </div>
</main> </main>
); );
+122 -38
View File
@@ -7,24 +7,27 @@ export interface CodecInfo {
encoders: string[]; encoders: string[];
} }
export async function getAvailableCodecs(): Promise<CodecInfo[]> { export type CodecList = {
vcodecs: CodecInfo[],
acodecs: CodecInfo[]
}
export async function getAvailableCodecs(): Promise<CodecList> {
const seperator = "-------"; const seperator = "-------";
const videoEncodingSupported = /.EV.../;
const wideFormattingSpaces = / {2,}/; const wideFormattingSpaces = / {2,}/;
const decodeEncodeSpecification = / \(((decoders)|(encoders)):.+\)/g; const decodeEncodeSpecification = / \(((decoders)|(encoders)):.+\)/g;
const result = await Neutralino.os.execCommand("ffmpeg -codecs"); const result = await Neutralino.os.execCommand("ffmpeg -codecs");
const rawCodecList = result.stdOut const rawCodecList = result.stdOut
.substring(result.stdOut.indexOf(seperator) + seperator.length) .substring(result.stdOut.indexOf(seperator) + seperator.length)
.split("\n"); .split("\n");
let codecs = []; let vcodecs = [];
let acodecs = [];
for (let codec of rawCodecList) { for (let codec of rawCodecList) {
codec = codec.trim(); codec = codec.trim();
const flags = codec.substring(0, 6); const flags = codec.substring(0, 6);
if (!videoEncodingSupported.test(flags)) { if (flags[1] !== "E") continue;
continue;
}
const nameAndDescription = codec const nameAndDescription = codec
.substring(7) .substring(7)
@@ -48,15 +51,49 @@ export async function getAvailableCodecs(): Promise<CodecInfo[]> {
.split(" "); .split(" ");
} }
codecs.push({ if (flags[2] === "V") {
flags, vcodecs.push({
shortName, flags,
description, shortName,
encoders, description,
}); encoders,
});
} else if (flags[2] === "A") {
acodecs.push({
flags,
shortName,
description,
encoders,
});
}
} }
return codecs; return {
vcodecs,
acodecs
};
}
export async function getPixelFormats(): Promise<string[]> {
const seperator = "-----";
const result = await Neutralino.os.execCommand("ffmpeg -pix_fmts");
const rawFormatList = result.stdOut
.substring(result.stdOut.indexOf(seperator) + seperator.length)
.split("\n");
let outputFormats = [];
for (let format of rawFormatList) {
format = format.trim();
const flags = format.substring(0, 5);
if (flags[1] !== "O") continue;
const parts = format.substring(6).split(/ +/);
outputFormats.push(parts[0]);
}
return outputFormats;
} }
export function playFile(path: string) { export function playFile(path: string) {
@@ -72,6 +109,12 @@ export const videoFileExtensions: { [key: string]: string } = {
vp9: "mkv", vp9: "mkv",
}; };
export interface ExtraFFmpegArguments {
global: string;
input: string;
output: string;
}
export interface FFmpegParams { export interface FFmpegParams {
inputFile?: string; inputFile?: string;
outputFile?: string; outputFile?: string;
@@ -93,6 +136,15 @@ export interface FFmpegParams {
faststart?: boolean; faststart?: boolean;
doNotUseAn?: boolean; doNotUseAn?: boolean;
speed?: number; speed?: number;
pixelFormat?: string;
/**
* Extra parameters defined by users
*/
useropts: ExtraFFmpegArguments;
/**
* Extra output parameters defined by Vencoder
*/
outputopts?: { [key: string]: string };
} }
const NULL_LOCATION = window.NL_OS === "Windows" ? "NUL" : "/dev/null"; const NULL_LOCATION = window.NL_OS === "Windows" ? "NUL" : "/dev/null";
@@ -103,40 +155,72 @@ const NULL_LOCATION = window.NL_OS === "Windows" ? "NUL" : "/dev/null";
*/ */
export const DEFAULT_BITRATE = 12000; export const DEFAULT_BITRATE = 12000;
function quickSyncVp9Command(params: FFmpegParams, opts: {
global: string,
input: string,
output: string,
}) {
return `ffmpeg -init_hw_device qsv=hw -filter_hw_device hw ${opts.global}${opts.input} -i "${params.inputFile ?? "{fileName}"}" -vf hwupload=extra_hw_frames=64,format=qsv -c:v vp9_qsv -c:a libopus${opts.output} -progress - "${params.outputFile ?? "{output}"}"`;
}
export function generateOutputCommand(params: FFmpegParams) { export function generateOutputCommand(params: FFmpegParams) {
let faststart = let faststart =
params.faststart && params.vcodec === "h264" params.faststart && params.vcodec === "h264"
? " -movflags +faststart" ? " -movflags +faststart"
: ""; : "";
if (params.twopass) { let globalopts = "-hwaccel auto -y";
const commonOpts = `-i "${params.inputFile ?? "{fileName}"}" -c:v ${params.encoder ?? params.vcodec} -b:v ${ let inputopts =
params.vbitrate ?? DEFAULT_BITRATE params.useropts.input !== "" ? " " + params.useropts.input : "";
}k${faststart}${ let outputopts =
params.preset === undefined ? "" : ` -preset ${params.preset}` params.useropts.output !== "" ? " " + params.useropts.output : "";
} -progress -`;
return `ffmpeg -hwaccel auto -y ${commonOpts} ${params.vcodec === "hevc" ? "-x265-params pass=1" : "-pass 1"} ${ if (params.useropts.global !== "") {
params.doNotUseAn ? "-vsync cfr" : "-an" globalopts += " " + params.useropts.global;
} -f null ${NULL_LOCATION} &&
ffmpeg -y -hwaccel auto ${commonOpts} ${
params.vcodec === "hevc" ? "-x265-params pass=2" : "-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}${ if (params.pixelFormat) {
params.crf === undefined ? "" : ` -crf ${params.crf}` if (params.outputopts === undefined) {
}${ params.outputopts = {};
params.vbitrate === undefined ? "" : ` -b:v ${params.vbitrate}` }
}${faststart}${
params.preset === undefined ? "" : ` -preset ${params.preset}` params.outputopts = {
} -c:a ${params.acodec ?? "copy"}${ "pix_fmt": params.pixelFormat
params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k` };
}${ }
params.speed === undefined ? "" : ` -speed ${params.speed}`
} -progress - "${params.outputFile ?? "{output}"}"`; if (params.outputopts !== undefined) {
for (const key of Object.keys(params.outputopts)) {
outputopts += ` -${key} ${params.outputopts[key]}`.trimEnd();
}
}
if (params.encoder === "vp9_qsv") {
return quickSyncVp9Command(params, {
global: globalopts,
input: inputopts,
output: outputopts
})
}
if (params.twopass) {
const commonOpts = `${globalopts}${inputopts} -i "${params.inputFile ?? "{fileName}"}" -c:v ${params.encoder ?? params.vcodec} -b:v ${params.vbitrate ?? DEFAULT_BITRATE
}k${faststart}${params.preset === undefined ? "" : ` -preset ${params.preset}`
} -progress -${outputopts}`;
return `ffmpeg ${commonOpts} ${params.vcodec === "hevc" ? "-x265-params pass=1" : "-pass 1"} ${params.doNotUseAn ? "-vsync cfr" : "-an"
} -f null ${NULL_LOCATION} &&
ffmpeg ${commonOpts} ${params.vcodec === "hevc" ? "-x265-params pass=2" : "-pass 2"
} -c:a ${params.acodec ?? "copy"
}${params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k`} "${params.outputFile ?? "{output}"}"`;
}
return `ffmpeg ${globalopts}${inputopts} -i "${params.inputFile ?? "{fileName}"}" -c:v ${params.encoder ?? params.vcodec}${params.crf === undefined ? "" : ` -crf ${params.crf}`
}${params.vbitrate === undefined ? "" : ` -b:v ${params.vbitrate}`
}${faststart}${params.preset === undefined ? "" : ` -preset ${params.preset}`
} -c:a ${params.acodec ?? "copy"}${params.abitrate === undefined ? "" : ` -b:a ${params.abitrate}k`
}${params.speed === undefined ? "" : ` -speed ${params.speed}`
} -progress -${outputopts} "${params.outputFile ?? "{output}"}"`;
} }
export async function getLengthMicroseconds(target: string) { export async function getLengthMicroseconds(target: string) {
+11
View File
@@ -1,3 +1,5 @@
import Neutralino from "@neutralinojs/lib";
export function getTemporaryFilePath() { export function getTemporaryFilePath() {
switch (window.NL_OS) { switch (window.NL_OS) {
case "Windows": case "Windows":
@@ -8,3 +10,12 @@ export function getTemporaryFilePath() {
return "."; return ".";
} }
} }
export async function getVencoderFolder() {
switch (window.NL_OS) {
case "Linux":
return `${await Neutralino.os.getEnv("HOME")}/Vencoder/`;
case "Windows":
return `${await Neutralino.os.getEnv("HOMEPATH")}\\Vencoder\\`;
}
}