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>
import { onMount } from "svelte";
import { addNode, nodes, inputs, output, filters, previewCommand } from "./stores.js";
import Input from "./Input.svelte";
import Output from "./Output.svelte";
import {
selectedFilter,
addNode,
nodes,
inputs,
output,
filters,
previewCommand,
} from "./stores.js";
import Filter from "./Filter.svelte";
import FilterPicker from "./FilterPicker.svelte";
import Graph from "./Graph.svelte";
// import GraphOld from "./GraphOld.svelte";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
import { dndzone } from "svelte-dnd-action";
@ -46,7 +51,11 @@
for (let vid of $inputs) {
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);
// command.push("-pix_fmt");
// command.push("yuv420p");
@ -84,11 +93,6 @@
ffmpegLoaded = true;
}
function handleFilterSort(e) {
filters.set(e.detail.items);
}
@ -105,50 +109,49 @@
<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 and options. To use: select one or more input videos (there are currently two options),
export and add some filters, and then hit "render" to preview the output in browser. Note: this
is a work in progress, many things may still be broken! Only audio to audio and video to video
filters are included. If it hangs/crashes refresh the page. Post issues/feedback to
filters. To use:
</p>
<ol>
<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://lav.io" target="_blank">Sam Lavigne</a>.
</p>
</div>
</section>
<!-- {message} -->
<section class="command">
<h3>Output Command</h3>
<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>
<button on:click={copyCommand}>Copy Command</button>
</div>
</div>
</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">
<h3>FFmpeg Log</h3>
<textarea 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...</span></div>
{/if}
<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}>
{#if ffmpegLoaded}
{#if rendering}
@ -163,58 +166,39 @@
</div>
</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">
<h3>Filters (click to add)</h3>
<div class="inner-filters">
<div class="filter-picker">
<FilterPicker select={"video"} />
</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 class="graph"> -->
<!-- <GraphOld /> -->
<!-- </section> -->
<section class="graph">
<Graph />
</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: repeat(3, 1fr);
grid-template-columns: 300px 1fr 1fr 1fr 1fr 300px;
grid-template-areas:
"hdr cmd cmd"
"inp log prv"
"out log prv"
"flt flt flt";
"hdr log log log prv prv"
"hdr cmd cmd cmd prv prv"
"flt gra gra gra gra edt";
/* grid-template-rows: 16% 17% 77%; */
grid-template-rows: 15% 15% calc(70% - 40px);
padding: 10px;
grid-gap: 20px;
padding: 20px;
height: 100vh;
border: 1px solid blue;
align-items: stretch;
}
section {
@ -228,6 +212,7 @@
.header {
grid-area: hdr;
overflow: scroll;
}
.command {
@ -236,17 +221,26 @@
flex-direction: column;
}
.inputs {
grid-area: inp;
}
.preview {
grid-area: prv;
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
}
.output {
grid-area: out;
.preview video {
width: 100%;
/* object-fit: contain; */
flex: 1;
}
.vid-holder {
flex: 1;
display: flex;
width: 100%;
height: calc(100% - 30px);
}
.log {
@ -255,19 +249,25 @@
flex-direction: column;
}
.inner-filters {
.filters {
grid-area: flt;
display: flex;
flex-direction: column;
}
.graph {
grid-area: gra;
display: flex;
}
.filters {
grid-area: flt;
.filter-editor {
grid-area: edt;
display: flex;
}
.filter-picker {
max-height: 500px;
width: 400px;
position: sticky;
/* max-height: 500px; */
/* width: 400px; */
top: 0;
left: 0;
overflow: scroll;
@ -302,14 +302,20 @@
.inner-command {
display: flex;
align-items: center;
margin: 10px 0px;
flex: 1;
}
textarea {
outline: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
resize: none;
}
.actual-command {
border: none;
margin-right: 10px;
resize: none;
flex: 1;
font: inherit;
padding: 5px;
@ -324,6 +330,7 @@
.the-log {
border: none;
resize: none;
padding: 5px;
flex: 1;
}
.rendering-video {
@ -339,6 +346,17 @@
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) {
.filters-holder {
grid-template-columns: repeat(2, 1fr);
@ -368,9 +386,6 @@
padding: 10px;
box-shadow: 2px 2px 0px #000;
}
.inner-filters {
display: block;
}
.filter-picker {
width: 100%;
margin-bottom: 20px;

View File

@ -7,7 +7,7 @@
description: "",
};
let show = false;
let show = true;
function remove() {
removeNode(filter.id);
@ -27,12 +27,6 @@
<div class="filter-holder">
<div class="head">
<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 class="description">
@ -93,6 +87,8 @@
/* border: 1px solid #999; */
border: 1px solid var(--b1);
/* box-shadow: 5px 5px 0px #000; */
overflow-y: scroll;
flex: 1;
}
.filter-holder,
input,
@ -123,6 +119,10 @@
}
.p-value {
display: flex;
justify-content: stretch;
}
input {
width: 100%;
}
input[type="range"] {
margin-right: 5px;

0
src/FilterEditor Normal file
View File

View File

@ -47,7 +47,7 @@
<div class="holder">
<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}>
<option value="video">Video Filters</option>
<option value="audio">Audio Filters</option>
@ -73,10 +73,14 @@
}
.search {
display: flex;
justify-items: stretch;
justify-content: stretch;
}
input {
width: 100%;
flex: 1;
}
button {
margin-left: 1px;
margin-right: 10px;
}
.type {

View File

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

View File

@ -1,5 +1,5 @@
<script>
import { nodes, edges, auto } from "./stores.js";
import { addNode, nodes, edges, auto, selectedFilter } from "./stores.js";
import {
SvelteFlowProvider,
SvelteFlow,
@ -14,36 +14,63 @@
ffmpeg: Node,
};
const defaultEdgeOptions = {
deletable: true,
};
function onEdgeUpdate(e) {
console.log(e);
function onClick(e) {
if (e.detail.nodeType === "filter") {
const newSelected = $nodes.findIndex((n) => n.id === e.detail.id);
if (newSelected > -1) {
$selectedFilter = newSelected;
}
}
}
function onEdgeUpdateStart(e) {
console.log(e);
function addInput() {
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>
<SvelteFlowProvider>
<div class="holder">
<FitComp />
<label for="auto"><input id="auto" type="checkbox" bind:checked={$auto} />Automatic Layout</label>
<div style="width: 900px; height: 500px;">
<SvelteFlow {nodeTypes} {nodes} {edges} snapGrid={[10, 10]} fitView>
<div class="nav">
<button on:click={addInput}>Add Input</button>
<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 />
<Background variant={BackgroundVariant.Dots} />
</SvelteFlow>
</div>
</div>
</div>
</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:
16px Times,
serif;
margin: 0;
}
input {
outline: none;
}
textrea,
select,
@ -32,7 +37,9 @@ button {
font: inherit;
}
button, input:not([type="range"]) , select {
button,
input:not([type="range"]),
select {
border: 1px solid var(--b1);
background-color: white;
box-shadow: 2px 2px 0px var(--b2);
@ -43,9 +50,7 @@ button:active {
left: 2px;
box-shadow: none;
}
video {
width: 100%;
}
input[type="range"] {
-webkit-appearance: none;

View File

@ -1,28 +1,96 @@
<script lang="ts">
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>
<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}
{/if}
</div>
</div>
{#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 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}
<!-- <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>
: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 {
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>

View File

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