530 lines
12 KiB
Svelte
530 lines
12 KiB
Svelte
<script>
|
|
import { onMount } from "svelte";
|
|
import {
|
|
selectedFilter,
|
|
nodes,
|
|
edges,
|
|
inputs,
|
|
outputs,
|
|
auto,
|
|
addNode,
|
|
inputNames,
|
|
previewCommand,
|
|
doFit,
|
|
} from "./stores.js";
|
|
import Filter from "./Filter.svelte";
|
|
import FilterPicker from "./FilterPicker.svelte";
|
|
import Graph from "./Graph.svelte";
|
|
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
|
import { fetchFile, toBlobURL } from "@ffmpeg/util";
|
|
|
|
const isChrome = navigator.userAgent.match(/chrome|chromium|crios/i);
|
|
const baseURL = `https://unpkg.com/@ffmpeg/core${!isChrome ? "-mt" : ""}@0.12.2/dist/esm`;
|
|
const TIMEOUT = 40000;
|
|
const ffmpeg = new FFmpeg();
|
|
const examples = [
|
|
{ name: "Cross Fade", url: "/examples/xfade.json" },
|
|
{ name: "Crop & Trim", url: "/examples/crop_trim.json" },
|
|
{ name: "Scale & Overlay", url: "/examples/scale_overlay.json" },
|
|
{ name: "Text", url: "/examples/text.json" },
|
|
{ name: "Speed Up", url: "/examples/speedup.json" },
|
|
{ name: "Slow Down Smoothly", url: "/examples/smooth_slow.json" },
|
|
{ name: "Video Grid", url: "/examples/grid.json" },
|
|
];
|
|
|
|
let videoValue = "/" + $inputs[0].name;
|
|
let ffmpegLoaded = false;
|
|
let rendering = false;
|
|
let log = "";
|
|
let logbox;
|
|
let commandRef;
|
|
let vidPlayerRef;
|
|
let renderProgress = 0;
|
|
let fileinput;
|
|
let fit;
|
|
|
|
function addInput() {
|
|
addNode({ ...$inputNames[$inputNames.length - 1] }, "input");
|
|
}
|
|
|
|
async function loadExample(url) {
|
|
if (!url) return;
|
|
$auto = false;
|
|
const response = await fetch(url);
|
|
const example = await response.json();
|
|
$nodes = example.nodes;
|
|
$edges = example.edges;
|
|
$doFit++;
|
|
}
|
|
|
|
async function onFileSelected(e) {
|
|
let vid = e.target.files[0];
|
|
await ffmpeg.writeFile(vid.name, await fetchFile(vid));
|
|
$inputNames.push({ name: vid.name, url: vid.name });
|
|
addInput();
|
|
}
|
|
|
|
function copyCommand() {
|
|
commandRef.select();
|
|
document.execCommand("copy");
|
|
}
|
|
|
|
async function render() {
|
|
renderProgress = 0;
|
|
videoValue = null;
|
|
rendering = true;
|
|
|
|
const outname = $outputs[0].name;
|
|
|
|
try {
|
|
if (log.trim() != "") log += "\n\n";
|
|
|
|
const fontNames = [
|
|
...new Set([...$previewCommand.join(" ").matchAll(/\W([a-z]+\.ttf)/g)].map((f) => f[1])),
|
|
];
|
|
|
|
for (let f of fontNames) {
|
|
await ffmpeg.writeFile(f, await fetchFile("/" + f));
|
|
}
|
|
|
|
let clist = [...$previewCommand];
|
|
clist.shift(); // remove "ffmpeg" from start of command
|
|
clist.unshift("-hide_banner", "-loglevel", "error");
|
|
clist = clist.map((c) => c.replaceAll('"', ""));
|
|
if (outname.endsWith("mp4")) {
|
|
clist.splice(clist.length - 1, 0, "-pix_fmt");
|
|
clist.splice(clist.length - 1, 0, "yuv420p");
|
|
}
|
|
await ffmpeg.exec(clist, TIMEOUT);
|
|
const data = await ffmpeg.readFile(outname);
|
|
videoValue = URL.createObjectURL(new Blob([data.buffer], { type: "video/mp4" }));
|
|
if (outname.endsWith("mp4")) {
|
|
setTimeout(() => {
|
|
vidPlayerRef.seekToNextFrame();
|
|
}, 100);
|
|
}
|
|
} catch (e) {
|
|
log += e + "\n";
|
|
}
|
|
rendering = false;
|
|
}
|
|
|
|
async function loadFFmpeg() {
|
|
ffmpeg.on("log", ({ type, message }) => {
|
|
if (message.trim() === "Aborted()") return;
|
|
|
|
log += message + "\n";
|
|
logbox.scrollTop = logbox.scrollHeight;
|
|
});
|
|
ffmpeg.on("progress", ({ progress }) => {
|
|
if (progress > 1) progress = 0;
|
|
renderProgress = progress;
|
|
});
|
|
if (isChrome) {
|
|
await ffmpeg.load({
|
|
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
|
|
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
|
|
// workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, "text/javascript"),
|
|
});
|
|
} else {
|
|
await ffmpeg.load({
|
|
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
|
|
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
|
|
workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, "text/javascript"),
|
|
});
|
|
}
|
|
for (let vid of $inputNames) {
|
|
await ffmpeg.writeFile(vid.name, await fetchFile(vid.url));
|
|
}
|
|
ffmpegLoaded = true;
|
|
}
|
|
|
|
onMount(async () => {
|
|
vidPlayerRef.volume = 0.5;
|
|
loadFFmpeg();
|
|
});
|
|
</script>
|
|
|
|
<main>
|
|
<section class="header">
|
|
<h1>FFmpeg Explorer</h1>
|
|
<div class="help">
|
|
<p>
|
|
A tool to help you explore <a href="https://www.ffmpeg.org/" target="_blank">FFmpeg</a>
|
|
filters. To use:
|
|
</p>
|
|
<ol>
|
|
<li>Add filters from the list on the left.</li>
|
|
<li>Click on filters in the node editor to modify options.</li>
|
|
<li>Hit "render" to preview the output in browser.</li>
|
|
<li>To edit the graph, disable "lock layout."</li>
|
|
</ol>
|
|
<p>
|
|
Note: work in progress, many things may be broken! Refresh if it hangs or crashes. May not
|
|
work on mobile. Post issues/feedback to
|
|
<a href="https://github.com/antiboredom/ffmpeg-explorer/" target="_blank">GitHub</a>. By
|
|
<a href="https://lav.io" target="_blank">Sam Lavigne</a>.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
<!-- {message} -->
|
|
<section class="command">
|
|
<div class="section-head">
|
|
<h3>Output Command</h3>
|
|
<div>
|
|
<button on:click={copyCommand}>Copy Command</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="inner-command">
|
|
<textarea
|
|
rows="1"
|
|
readonly
|
|
class="actual-command"
|
|
bind:this={commandRef}
|
|
on:click={() => commandRef.select()}>{$previewCommand.join(" ")}</textarea
|
|
>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="log">
|
|
<div class="section-head">
|
|
<h3>Error Log</h3>
|
|
<div>
|
|
<button
|
|
on:click={() => {
|
|
log = "";
|
|
}}>Clear Errors</button
|
|
>
|
|
</div>
|
|
</div>
|
|
<textarea rows="1" readonly class="the-log" bind:this={logbox}>{log}</textarea>
|
|
</section>
|
|
|
|
<section class="preview">
|
|
<div class="vid-holder">
|
|
{#if rendering}
|
|
<div class="rendering-video">
|
|
<span>Rendering...{(renderProgress * 100).toFixed(2)}%</span>
|
|
</div>
|
|
{/if}
|
|
{#if $outputs[0].name.endsWith("gif") && videoValue && !videoValue.endsWith("mp4")}
|
|
<img src={videoValue} />
|
|
{:else}
|
|
<video bind:this={vidPlayerRef} controls src={videoValue} />
|
|
{/if}
|
|
</div>
|
|
<div style="text-align: right;padding-top:5px;">
|
|
<button on:click={render} disabled={!ffmpegLoaded || rendering}>
|
|
{#if ffmpegLoaded}
|
|
{#if rendering}
|
|
Rendering...
|
|
{:else}
|
|
Render Preview
|
|
{/if}
|
|
{:else}
|
|
Loading ffmpeg
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="filters">
|
|
<h3>Filters (click to add)</h3>
|
|
<div class="filter-picker">
|
|
<FilterPicker select={"video"} />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="graph">
|
|
<div class="graph-holder">
|
|
<div class="graph-nav">
|
|
<div>
|
|
<button on:click={addInput}>Add Sample Input</button>
|
|
<button on:click={() => fileinput.click()}>Upload File</button>
|
|
<input
|
|
type="file"
|
|
accept="video/*"
|
|
on:change={(e) => onFileSelected(e)}
|
|
bind:this={fileinput}
|
|
style="display: none;"
|
|
/>
|
|
<label for="auto"
|
|
><input id="auto" type="checkbox" bind:checked={$auto} />Lock Layout</label
|
|
>
|
|
</div>
|
|
|
|
<div class="examples">
|
|
<label>Examples</label>
|
|
<select on:change={(e) => loadExample(e.target.value)}>
|
|
<option value="" />
|
|
{#each examples as example}
|
|
<option value={example.url}>{example.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<Graph bind:this={fit} />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="filter-editor">
|
|
{#if $selectedFilter && $nodes.length > 0 && $nodes[$selectedFilter]}
|
|
<Filter bind:filter={$nodes[$selectedFilter].data} />
|
|
{/if}
|
|
</section>
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
display: grid;
|
|
grid-template-columns: 300px 1fr 1fr 1fr 1fr 300px;
|
|
grid-template-areas:
|
|
"hdr log log log prv prv"
|
|
"hdr cmd cmd cmd prv prv"
|
|
"flt gra gra gra gra edt";
|
|
grid-template-rows: 15% 15% calc(70% - 40px);
|
|
padding: 10px;
|
|
grid-gap: 20px;
|
|
height: 100vh;
|
|
align-items: stretch;
|
|
}
|
|
|
|
section {
|
|
/* border: 1px solid #999; */
|
|
/* box-shadow: 7px 7px 0px rgba(0, 0, 0, 0.7); */
|
|
border: 1px solid var(--b1);
|
|
box-shadow: 7px 7px var(--b2);
|
|
padding: 10px;
|
|
background-color: rgb(245, 245, 245);
|
|
}
|
|
|
|
.header {
|
|
grid-area: hdr;
|
|
overflow: scroll;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.command {
|
|
grid-area: cmd;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.preview {
|
|
grid-area: prv;
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
justify-content: stretch;
|
|
}
|
|
|
|
.preview video,
|
|
.preview img {
|
|
width: 100%;
|
|
background-color: #000;
|
|
flex: 1;
|
|
}
|
|
|
|
.preview img {
|
|
object-fit: contain;
|
|
}
|
|
|
|
.vid-holder {
|
|
flex: 1;
|
|
display: flex;
|
|
width: 100%;
|
|
height: calc(100% - 30px);
|
|
}
|
|
|
|
.log {
|
|
grid-area: log;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.filters {
|
|
grid-area: flt;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.graph {
|
|
grid-area: gra;
|
|
display: flex;
|
|
}
|
|
|
|
.filter-editor {
|
|
grid-area: edt;
|
|
display: flex;
|
|
}
|
|
|
|
.section-head {
|
|
display: flex;
|
|
justify-content: stretch;
|
|
}
|
|
.section-head h3 {
|
|
flex: 1;
|
|
}
|
|
|
|
.filter-picker {
|
|
/* max-height: 500px; */
|
|
/* width: 400px; */
|
|
top: 0;
|
|
left: 0;
|
|
overflow: scroll;
|
|
background-color: #fff;
|
|
}
|
|
|
|
h1,
|
|
h3 {
|
|
font-weight: normal;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
h1 {
|
|
margin-bottom: 5px;
|
|
}
|
|
h3 {
|
|
margin-bottom: 10px;
|
|
}
|
|
.header p {
|
|
margin: 0;
|
|
}
|
|
|
|
.inner-command {
|
|
display: flex;
|
|
align-items: center;
|
|
flex: 1;
|
|
}
|
|
|
|
textarea {
|
|
outline: none;
|
|
-webkit-box-shadow: none;
|
|
-moz-box-shadow: none;
|
|
box-shadow: none;
|
|
resize: none;
|
|
height: auto;
|
|
font: inherit;
|
|
}
|
|
|
|
.actual-command {
|
|
border: none;
|
|
flex: 1;
|
|
padding: 5px;
|
|
height: 100%;
|
|
}
|
|
.the-log {
|
|
color: red;
|
|
border: none;
|
|
resize: none;
|
|
padding: 5px;
|
|
flex: 1;
|
|
}
|
|
.rendering-video {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
opacity: 0.97;
|
|
background-color: var(--b2);
|
|
z-index: 2;
|
|
top: 0;
|
|
left: 0;
|
|
display: grid;
|
|
align-items: center;
|
|
justify-content: center;
|
|
align-content: center;
|
|
}
|
|
|
|
.help {
|
|
font-size: 0.9em;
|
|
flex: 1;
|
|
overflow: scroll;
|
|
}
|
|
ol {
|
|
margin: 5px 0px;
|
|
padding-left: 20px;
|
|
}
|
|
ol li {
|
|
margin-bottom: 5px;
|
|
}
|
|
.graph-holder {
|
|
flex-direction: column;
|
|
display: flex;
|
|
flex: 1;
|
|
}
|
|
|
|
.graph-nav {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
@media only screen and (max-width: 1100px) {
|
|
main {
|
|
grid-template-columns: 250px 1fr 1fr 1fr 1fr 300px;
|
|
grid-template-rows: 40px 15% 15% calc(35% - 25px) calc(35% - 40px);
|
|
grid-template-areas:
|
|
"hdr hdr hdr hdr hdr hdr"
|
|
"log log log log prv prv"
|
|
"cmd cmd cmd cmd prv prv"
|
|
"flt gra gra gra gra gra"
|
|
"edt gra gra gra gra gra";
|
|
grid-gap: 7px;
|
|
}
|
|
.header {
|
|
overflow: hidden;
|
|
}
|
|
section {
|
|
padding: 7px;
|
|
box-shadow: 3px 3px 0px var(--b2);
|
|
}
|
|
}
|
|
|
|
@media only screen and (max-width: 700px) {
|
|
main {
|
|
grid-template-areas:
|
|
"hdr hdr hdr"
|
|
"cmd cmd cmd"
|
|
"prv prv prv"
|
|
"log log log"
|
|
"flt flt flt"
|
|
"gra gra gra"
|
|
"edt edt edt";
|
|
grid-gap: 0;
|
|
padding: 10px;
|
|
height: auto;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
grid-template-rows: auto;
|
|
}
|
|
.header {
|
|
overflow: scroll;
|
|
height: auto;
|
|
}
|
|
.graph {
|
|
height: 60vh;
|
|
}
|
|
.command {
|
|
margin: 0;
|
|
margin-bottom: 10px;
|
|
}
|
|
.command,
|
|
.log {
|
|
height: 15vh;
|
|
}
|
|
section {
|
|
box-shadow: none;
|
|
margin-bottom: 10px;
|
|
padding: 10px;
|
|
box-shadow: 2px 2px 0px var(--b2);
|
|
}
|
|
.filter-picker {
|
|
width: 100%;
|
|
margin-bottom: 20px;
|
|
height: 300px;
|
|
position: static;
|
|
}
|
|
}
|
|
</style>
|