working graph editor

This commit is contained in:
Sam Lavigne 2023-08-24 23:17:49 -04:00
parent f6b4e43509
commit 0645651fd9
9 changed files with 338 additions and 209 deletions

View File

@ -1,12 +1,17 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { addNode, nodes, inputs, output, filters, previewCommand } from "./stores.js"; import {
import Input from "./Input.svelte"; selectedFilter,
import Output from "./Output.svelte"; addNode,
nodes,
inputs,
output,
filters,
previewCommand,
} from "./stores.js";
import Filter from "./Filter.svelte"; import Filter from "./Filter.svelte";
import FilterPicker from "./FilterPicker.svelte"; import FilterPicker from "./FilterPicker.svelte";
import Graph from "./Graph.svelte"; import Graph from "./Graph.svelte";
// import GraphOld from "./GraphOld.svelte";
import { FFmpeg } from "@ffmpeg/ffmpeg"; import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util"; import { fetchFile, toBlobURL } from "@ffmpeg/util";
import { dndzone } from "svelte-dnd-action"; import { dndzone } from "svelte-dnd-action";
@ -46,7 +51,11 @@
for (let vid of $inputs) { for (let vid of $inputs) {
await ffmpeg.writeFile(vid.name, await fetchFile("/" + vid.name)); await ffmpeg.writeFile(vid.name, await fetchFile("/" + vid.name));
} }
let clist = $previewCommand.replaceAll('"', '').replace("ffmpeg", "").split(" ").filter(i => i.trim() != ''); let clist = $previewCommand
.replaceAll('"', "")
.replace("ffmpeg", "")
.split(" ")
.filter((i) => i.trim() != "");
console.log("command", clist); console.log("command", clist);
// command.push("-pix_fmt"); // command.push("-pix_fmt");
// command.push("yuv420p"); // command.push("yuv420p");
@ -84,11 +93,6 @@
ffmpegLoaded = true; ffmpegLoaded = true;
} }
function handleFilterSort(e) { function handleFilterSort(e) {
filters.set(e.detail.items); filters.set(e.detail.items);
} }
@ -105,50 +109,49 @@
<main> <main>
<section class="header"> <section class="header">
<h1>FFmpeg Explorer</h1> <h1>FFmpeg Explorer</h1>
<div class="help">
<p> <p>
A tool to help you explore <a href="https://www.ffmpeg.org/" target="_blank">FFmpeg</a> A tool to help you explore <a href="https://www.ffmpeg.org/" target="_blank">FFmpeg</a>
filters and options. To use: select one or more input videos (there are currently two options), filters. To use:
export and add some filters, and then hit "render" to preview the output in browser. Note: this </p>
is a work in progress, many things may still be broken! Only audio to audio and video to video <ol>
filters are included. If it hangs/crashes refresh the page. Post issues/feedback to <li>Add filters from the list on the left.</li>
<li>Click on filters in the center panel to edit options.</li>
<li>Hit "render" to preview the output in browser.</li>
<li>For more complex filtergraphs, disable "automatic layout."</li>
</ol>
<p>
Note: this is a work in progress, many things may still be broken! If it hangs/crashes
refresh the page. Post issues/feedback to
<a href="https://github.com/antiboredom/ffmpeg-explorer/" target="_blank">GitHub</a>. By <a href="https://github.com/antiboredom/ffmpeg-explorer/" target="_blank">GitHub</a>. By
<a href="https://lav.io" target="_blank">Sam Lavigne</a>. <a href="https://lav.io" target="_blank">Sam Lavigne</a>.
</p> </p>
</div>
</section> </section>
<!-- {message} --> <!-- {message} -->
<section class="command"> <section class="command">
<h3>Output Command</h3> <h3>Output Command</h3>
<div class="inner-command"> <div class="inner-command">
<textarea readonly class="actual-command" bind:this={commandRef}>{$previewCommand}</textarea> <textarea readonly class="actual-command" bind:this={commandRef} on:click={() => commandRef.select()}>{$previewCommand}</textarea>
<div> <div>
<button on:click={copyCommand}>Copy Command</button> <button on:click={copyCommand}>Copy Command</button>
</div> </div>
</div> </div>
</section> </section>
<section class="inputs">
<div class="section-header">
<h3>Inputs</h3>
<button on:click={newInput}>Add Input</button>
</div>
{#each $nodes as node, index}
{#if node.nodeType === "input"}
<Input bind:filename={node.data.name} id={node.id} {index} />
{/if}
{/each}
</section>
<section class="log"> <section class="log">
<h3>FFmpeg Log</h3> <h3>FFmpeg Log</h3>
<textarea readonly class="the-log" bind:this={logbox}>{log}</textarea> <textarea readonly class="the-log" bind:this={logbox}>{log}</textarea>
</section> </section>
<section class="preview"> <section class="preview">
<div class="vid-holder">
{#if rendering} {#if rendering}
<div class="rendering-video"><span>Rendering...</span></div> <div class="rendering-video"><span>Rendering...</span></div>
{/if} {/if}
<video controls src={videoValue} /> <video controls src={videoValue} />
<div style="text-align: right;margin-top:5px;"> </div>
<div style="text-align: right;padding-top:5px;">
<button on:click={render} disabled={!ffmpegLoaded || rendering}> <button on:click={render} disabled={!ffmpegLoaded || rendering}>
{#if ffmpegLoaded} {#if ffmpegLoaded}
{#if rendering} {#if rendering}
@ -163,58 +166,39 @@
</div> </div>
</section> </section>
<section class="output">
<h3>Output</h3>
{#each $nodes as node}
{#if node.nodeType==="output"}
<Output bind:filename={node.data.name} />
{/if}
{/each}
</section>
<section class="filters"> <section class="filters">
<h3>Filters (click to add)</h3> <h3>Filters (click to add)</h3>
<div class="inner-filters">
<div class="filter-picker"> <div class="filter-picker">
<FilterPicker select={"video"} /> <FilterPicker select={"video"} />
</div> </div>
<div
class="filters-holder"
use:dndzone={{ items: $filters }}
on:consider={handleFilterSort}
on:finalize={handleFilterSort}
>
{#each $nodes as f (f.id)}
{#if f.nodeType === "filter"}
<div class="filter">
<Filter bind:filter={f.data} />
</div>
{/if}
{/each}
</div>
</div>
</section> </section>
<!-- <section class="graph"> -->
<!-- <GraphOld /> -->
<!-- </section> -->
<section class="graph"> <section class="graph">
<Graph /> <Graph />
</section> </section>
<section class="filter-editor">
{#if $selectedFilter && $nodes.length > 0 && $nodes[$selectedFilter]}
<Filter bind:filter={$nodes[$selectedFilter].data} />
{/if}
</section>
</main> </main>
<style> <style>
main { main {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: 300px 1fr 1fr 1fr 1fr 300px;
grid-template-areas: grid-template-areas:
"hdr cmd cmd" "hdr log log log prv prv"
"inp log prv" "hdr cmd cmd cmd prv prv"
"out log prv" "flt gra gra gra gra edt";
"flt flt flt"; /* grid-template-rows: 16% 17% 77%; */
grid-template-rows: 15% 15% calc(70% - 40px);
padding: 10px;
grid-gap: 20px; grid-gap: 20px;
padding: 20px; height: 100vh;
border: 1px solid blue;
align-items: stretch;
} }
section { section {
@ -228,6 +212,7 @@
.header { .header {
grid-area: hdr; grid-area: hdr;
overflow: scroll;
} }
.command { .command {
@ -236,17 +221,26 @@
flex-direction: column; flex-direction: column;
} }
.inputs {
grid-area: inp;
}
.preview { .preview {
grid-area: prv; grid-area: prv;
position: relative; position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
} }
.output { .preview video {
grid-area: out; width: 100%;
/* object-fit: contain; */
flex: 1;
}
.vid-holder {
flex: 1;
display: flex;
width: 100%;
height: calc(100% - 30px);
} }
.log { .log {
@ -255,19 +249,25 @@
flex-direction: column; flex-direction: column;
} }
.inner-filters { .filters {
grid-area: flt;
display: flex;
flex-direction: column;
}
.graph {
grid-area: gra;
display: flex; display: flex;
} }
.filters { .filter-editor {
grid-area: flt; grid-area: edt;
display: flex;
} }
.filter-picker { .filter-picker {
max-height: 500px; /* max-height: 500px; */
width: 400px; /* width: 400px; */
position: sticky;
top: 0; top: 0;
left: 0; left: 0;
overflow: scroll; overflow: scroll;
@ -302,14 +302,20 @@
.inner-command { .inner-command {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 10px 0px;
flex: 1; flex: 1;
} }
textarea {
outline: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
resize: none;
}
.actual-command { .actual-command {
border: none; border: none;
margin-right: 10px; margin-right: 10px;
resize: none;
flex: 1; flex: 1;
font: inherit; font: inherit;
padding: 5px; padding: 5px;
@ -324,6 +330,7 @@
.the-log { .the-log {
border: none; border: none;
resize: none; resize: none;
padding: 5px;
flex: 1; flex: 1;
} }
.rendering-video { .rendering-video {
@ -339,6 +346,17 @@
justify-content: center; justify-content: center;
} }
.help {
font-size: 0.9em;
}
ol {
margin: 5px 0px;
padding-left: 20px;
}
ol li {
margin-bottom: 5px;
}
@media only screen and (max-width: 1400px) { @media only screen and (max-width: 1400px) {
.filters-holder { .filters-holder {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -368,9 +386,6 @@
padding: 10px; padding: 10px;
box-shadow: 2px 2px 0px #000; box-shadow: 2px 2px 0px #000;
} }
.inner-filters {
display: block;
}
.filter-picker { .filter-picker {
width: 100%; width: 100%;
margin-bottom: 20px; margin-bottom: 20px;

View File

@ -7,7 +7,7 @@
description: "", description: "",
}; };
let show = false; let show = true;
function remove() { function remove() {
removeNode(filter.id); removeNode(filter.id);
@ -27,12 +27,6 @@
<div class="filter-holder"> <div class="filter-holder">
<div class="head"> <div class="head">
<div class="name"><h3>{filter.name}</h3></div> <div class="name"><h3>{filter.name}</h3></div>
<div>
{#if filter.params && filter.params.length > 0}
<button on:click={() => (show = !show)}>{show ? "Hide" : "Show"} Options</button>
{/if}
<button on:click={remove}>X</button>
</div>
</div> </div>
<div class="description"> <div class="description">
@ -93,6 +87,8 @@
/* border: 1px solid #999; */ /* border: 1px solid #999; */
border: 1px solid var(--b1); border: 1px solid var(--b1);
/* box-shadow: 5px 5px 0px #000; */ /* box-shadow: 5px 5px 0px #000; */
overflow-y: scroll;
flex: 1;
} }
.filter-holder, .filter-holder,
input, input,
@ -123,6 +119,10 @@
} }
.p-value { .p-value {
display: flex; display: flex;
justify-content: stretch;
}
input {
width: 100%;
} }
input[type="range"] { input[type="range"] {
margin-right: 5px; margin-right: 5px;

0
src/FilterEditor Normal file
View File

View File

@ -47,7 +47,7 @@
<div class="holder"> <div class="holder">
<div class="search"> <div class="search">
<input placeholder="Search Filters" on:keyup={update} bind:value={q} type="text" /> <input placeholder="Search Filters" on:keyup={update} bind:value={q} type="text" /><button on:click={() => {reset(); update();}}>X</button>
<select on:change={reset} bind:value={select}> <select on:change={reset} bind:value={select}>
<option value="video">Video Filters</option> <option value="video">Video Filters</option>
<option value="audio">Audio Filters</option> <option value="audio">Audio Filters</option>
@ -73,10 +73,14 @@
} }
.search { .search {
display: flex; display: flex;
justify-items: stretch; justify-content: stretch;
} }
input { input {
width: 100%;
flex: 1; flex: 1;
}
button {
margin-left: 1px;
margin-right: 10px; margin-right: 10px;
} }
.type { .type {

View File

@ -9,4 +9,4 @@
if ($auto) fitView(); if ($auto) fitView();
}); });
</script> </script>
<button>Fit</button>

View File

@ -1,5 +1,5 @@
<script> <script>
import { nodes, edges, auto } from "./stores.js"; import { addNode, nodes, edges, auto, selectedFilter } from "./stores.js";
import { import {
SvelteFlowProvider, SvelteFlowProvider,
SvelteFlow, SvelteFlow,
@ -14,36 +14,63 @@
ffmpeg: Node, ffmpeg: Node,
}; };
const defaultEdgeOptions = { function onClick(e) {
deletable: true, if (e.detail.nodeType === "filter") {
}; const newSelected = $nodes.findIndex((n) => n.id === e.detail.id);
if (newSelected > -1) {
function onEdgeUpdate(e) { $selectedFilter = newSelected;
console.log(e); }
}
} }
function onEdgeUpdateStart(e) { function addInput() {
console.log(e); addNode({ name: "shoe.mp4" }, "input");
} }
function onEdgeUpdateEnd(e) {
console.log(e);
}
function onMoveStart(e) {
console.log(e);
}
function onConnect(e) {
console.log("connect", e);
}
</script> </script>
<SvelteFlowProvider> <SvelteFlowProvider>
<div class="holder">
<FitComp /> <FitComp />
<label for="auto"><input id="auto" type="checkbox" bind:checked={$auto} />Automatic Layout</label> <div class="nav">
<div style="width: 900px; height: 500px;"> <button on:click={addInput}>Add Input</button>
<SvelteFlow {nodeTypes} {nodes} {edges} snapGrid={[10, 10]} fitView> <label for="auto"
><input id="auto" type="checkbox" bind:checked={$auto} />Automatic Layout</label
>
</div>
<div class="flow">
<div style="height: 100%; width: 100%">
<SvelteFlow
nodesConnectable={!auto}
panOnDrag={!auto}
edgesUpdatable={!auto}
connectOnClick={true}
nodesFocusable={!auto}
edgesFocusable={!auto}
on:nodeclick={onClick}
{nodeTypes}
{nodes}
{edges}
snapGrid={[10, 10]}
fitView
>
<Controls /> <Controls />
<Background variant={BackgroundVariant.Dots} /> <Background variant={BackgroundVariant.Dots} />
</SvelteFlow> </SvelteFlow>
</div> </div>
</div>
</div>
</SvelteFlowProvider> </SvelteFlowProvider>
<style>
.holder {
flex-direction: column;
display: flex;
flex: 1;
}
.flow {
flex: 1;
margin-top: 10px;
}
</style>

View File

@ -24,6 +24,11 @@ body {
font: font:
16px Times, 16px Times,
serif; serif;
margin: 0;
}
input {
outline: none;
} }
textrea, textrea,
select, select,
@ -32,7 +37,9 @@ button {
font: inherit; font: inherit;
} }
button, input:not([type="range"]) , select { button,
input:not([type="range"]),
select {
border: 1px solid var(--b1); border: 1px solid var(--b1);
background-color: white; background-color: white;
box-shadow: 2px 2px 0px var(--b2); box-shadow: 2px 2px 0px var(--b2);
@ -43,9 +50,7 @@ button:active {
left: 2px; left: 2px;
box-shadow: none; box-shadow: none;
} }
video {
width: 100%;
}
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;

View File

@ -1,28 +1,96 @@
<script lang="ts"> <script lang="ts">
import { Handle, Position } from "@xyflow/svelte"; import { Handle, Position } from "@xyflow/svelte";
import { removeNode } from "../stores.js";
export let data = { name: "", inputs: [], outputs: [], onChange: () => {} }; export let data = { nodeType: "", name: "", inputs: [], outputs: [] };
export let id;
function remove() {
removeNode(id);
}
</script> </script>
<div class="node"> <div class="node {data.nodeType}">
<div class="head">
<div class="node-type">{data.nodeType}</div>
<button on:click={remove}>X</button>
</div>
<div class="body">
{#if data.nodeType == "input"}
<select bind:value={data.name}>
<option value="punch.mp4">punch.mp4</option>
<option value="shoe.mp4">shoe.mp4</option>
</select>
{:else}
{data.name} {data.name}
{/if}
</div>
</div> </div>
{#each data.inputs as inp, index} {#each data.inputs as inp, index}
<Handle type="target" position={Position.Left} id={inp + "_" + index} style="top: {index*10}px; background-color: {inp == 'v' ? 'blue' : 'red'}" /> <Handle
type="target"
position={Position.Left}
id={inp + "_" + index}
class="handle {inp}"
style="top: {index * 12 + 4}px; left: -7px;">{inp}</Handle
>
{/each} {/each}
{#each data.outputs as out, index} {#each data.outputs as out, index}
<Handle type="source" id={out + "_" + index} position={Position.Right} style="top: {index*10}px; background-color: {out == 'v' ? 'blue' : 'red'}" /> <Handle
type="source"
id={out + "_" + index}
position={Position.Right}
class="handle {out}"
style="top: {index * 12 + 4}px; left: 107%;">{out}</Handle
>
{/each} {/each}
<!-- <Handle type="source" position={Position.Right} id="a" style="top: 10px;" on:connect /> -->
<!-- <Handle -->
<!-- type="source" -->
<!-- position={Position.Right} -->
<!-- id="b" -->
<!-- style="top: auto; bottom: 10px;" -->
<!-- on:connect -->
<!-- /> -->
<style> <style>
:global(:root) {
--edge-color: var(--b1) !important;
--edge-color-selected: black;
}
:global(.svelte-flow__node) {
box-shadow: 2px 2px 0px var(--b2);
}
:global(.svelte-flow__node.selected) {
outline: 1px solid var(--b1) !important;
}
:global(.handle) {
width: 10px !important;
height: 10px !important;
border: 1px solid var(--b1) !important;
border-radius: 0px !important;
background-color: white !important;
font-size: 10px;
line-height: 6px;
}
:global(.handle.a) {
/* border: 1px solid var(--b2) !important; */
}
.node { .node {
padding: 5px; padding: 5px;
} }
.head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.head button {
font-size: 10px;
line-height: 8px;
padding: 2px 2px;
margin-left: 10px;
}
.node-type {
font-size: 0.8em;
color: #999;
}
.node.input {
}
.node.filter {
}
.node.output {
}
</style> </style>

View File

@ -7,6 +7,7 @@ import { writable, derived, get } from "svelte/store";
export const nodes = writable([]); export const nodes = writable([]);
export const edges = writable([]); export const edges = writable([]);
export const auto = writable(true); export const auto = writable(true);
export const selectedFilter = writable();
const PREFIX = ""; const PREFIX = "";
@ -146,6 +147,8 @@ export const previewCommand = derived([edges, nodes], ([$edges, $nodes]) => {
const source = $nodes.find((n) => n.id === e.source); const source = $nodes.find((n) => n.id === e.source);
const target = $nodes.find((n) => n.id === e.target); const target = $nodes.find((n) => n.id === e.target);
if (source && target) {
if (source.nodeType === "input") { if (source.nodeType === "input") {
if (e.sourceHandle.includes("v")) { if (e.sourceHandle.includes("v")) {
edgeIds[e.id] = inputIds[source.id] + ":v"; edgeIds[e.id] = inputIds[source.id] + ":v";
@ -160,6 +163,7 @@ export const previewCommand = derived([edges, nodes], ([$edges, $nodes]) => {
edgeIds[e.id] = outType; edgeIds[e.id] = outType;
} }
} }
}
for (let n of $nodes.filter((n) => n.nodeType == "filter")) { for (let n of $nodes.filter((n) => n.nodeType == "filter")) {
let cmd = ""; let cmd = "";
@ -330,6 +334,7 @@ export function addNode(data, type) {
} }
} }
data.nodeType = type;
data.inputs = ins; data.inputs = ins;
data.outputs = outs; data.outputs = outs;
@ -347,9 +352,9 @@ export function addNode(data, type) {
const isAuto = get(auto); const isAuto = get(auto);
if (isAuto) { if (isAuto) {
const w = 100; const w = 120;
const h = 50; const h = 50;
const margin = 10; const margin = 50;
let prev = null; let prev = null;
for (let n of _nodes) { for (let n of _nodes) {
@ -362,7 +367,7 @@ export function addNode(data, type) {
for (let n of _nodes) { for (let n of _nodes) {
if (n.nodeType === "filter") { if (n.nodeType === "filter") {
let _w = prev && prev.width ? prev.width : w; let _w = prev && prev.width ? prev.width : w;
n.position = { x: prev ? prev.position.x + _w + margin : 0, y: -30 }; n.position = { x: prev ? prev.position.x + _w + margin : 0, y: -50 };
prev = n; prev = n;
} }
} }
@ -374,8 +379,13 @@ export function addNode(data, type) {
} }
} }
} }
if (node.nodeType === "filter") {
selectedFilter.set(_nodes.length - 1);
}
return _nodes; return _nodes;
}); });
} }
export function removeNode(id) { export function removeNode(id) {