Really basic functionality

This commit is contained in:
2025-07-29 16:45:17 +07:00
commit 43e29b1cae
38 changed files with 3441 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"tabWidth": 4
}
+30
View File
@@ -0,0 +1,30 @@
# Vencoder Solid.js Source
Contents from README.md generated by Vite (slightly modified):
## Usage
```bash
$ pnpm install
```
## Available Scripts
In the project directory, you can run:
### `pnpm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `pnpm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html)
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="http://localhost:37503/__neutralino_globals.js"></script>
<title>Vencoder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "solid-src",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@neutralinojs/lib": "^6.2.0",
"@solidjs/router": "^0.15.3",
"color-convert": "^3.1.0",
"solid-js": "^1.9.7"
},
"devDependencies": {
"prettier": "3.6.2",
"typescript": "~5.8.3",
"vite": "^7.0.6",
"vite-plugin-solid": "^2.11.7"
},
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad"
}
+1172
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild
@@ -0,0 +1,10 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<style
type="text/css"
id="current-color-scheme">
.ColorScheme-Text {
color:#e3e6e9;
}
</style>
<path d="M8 11.707l-6-6L2.707 5 8 10.293 13.293 5l.707.707-6 6z" class="ColorScheme-Text" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 332 B

@@ -0,0 +1,10 @@
<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>
<path d="M8 11.707l-6-6L2.707 5 8 10.293 13.293 5l.707.707-6 6z" class="ColorScheme-Text" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 332 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

+1
View File
@@ -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

+161
View File
@@ -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;
+208
View File
@@ -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);
}
+88
View File
@@ -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;
}
+50
View File
@@ -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!,
);
+98
View File
@@ -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;
+129
View File
@@ -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}"}"`;
}
+3
View File
@@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
export default defineConfig({
plugins: [solid()],
})