From ef892c6732475fac81120ac8abcf15df76d55ce8 Mon Sep 17 00:00:00 2001 From: Sam Lavigne Date: Tue, 29 Aug 2023 01:09:36 -0400 Subject: [PATCH] refactor command generator --- jsconfig.json | 2 +- src/App.svelte | 2 +- src/stores.js | 426 ++++++++++++++++------------- tests/unit-tests/commandbuilder.js | 35 ++- 4 files changed, 264 insertions(+), 201 deletions(-) diff --git a/jsconfig.json b/jsconfig.json index 5696a2d..3f145a3 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -22,7 +22,7 @@ * Typecheck JS in `.svelte` and `.js` files by default. * Disable this if you'd like to use dynamic types. */ - "checkJs": true + "checkJs": false }, /** * Use global.d.ts instead of compilerOptions.types diff --git a/src/App.svelte b/src/App.svelte index 981c646..7152e17 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -33,7 +33,7 @@ videoValue = null; rendering = true; try { - if (log.trim() != '') log += "\n\n"; + if (log.trim() != "") log += "\n\n"; for (let vid of $inputs) { await ffmpeg.writeFile(vid.name, await fetchFile("/" + vid.name)); } diff --git a/src/stores.js b/src/stores.js index ad1bfa2..58f3725 100644 --- a/src/stores.js +++ b/src/stores.js @@ -10,249 +10,283 @@ addNode({ name: "punch.mp4" }, "input"); addNode({ name: "out.mp4" }, "output"); export function makeFilterArgs(f) { - let fCommand = f.name; - if (f.params && f.params.length > 0) { - let params = f.params - .map((p) => { - if (p.value === "" || p.value === null || p.value === p.default) return null; - return `${p.name}=${p.value}`; - }) - .filter((p) => p !== null) - .join(":"); - if (params) fCommand += "=" + params; - } - return fCommand; + let fCommand = f.name; + if (f.params && f.params.length > 0) { + let params = f.params + .map((p) => { + if (p.value === "" || p.value === null || p.value === p.default) return null; + return `${p.name}=${p.value}`; + }) + .filter((p) => p !== null) + .join(":"); + if (params) fCommand += "=" + params; + } + return fCommand; } export const previewCommand = derived([edges, nodes], ([$edges, $nodes]) => { - let hasVid = false; - let hasAud = false; + let finalCommand = []; + let filtergraph = []; + let labelIndex = 1; + const edgeIds = {}; + const inputIdMap = {}; - let finalCommand = []; + const inputs = $nodes.filter((n) => n.nodeType === "input"); + const inputIds = inputs.map((n) => n.id); + const inputEdges = $edges.filter((e) => inputIds.includes(e.source)); + const outputs = $nodes.filter((n) => n.nodeType === "output"); - let filtergraph = []; + inputs.forEach((inp, i) => (inputIdMap[inp.id] = i)); - const inputs = $nodes.filter((n) => n.nodeType == "input"); - const outputs = $nodes.filter((n) => n.nodeType == "output"); + function traverseEdges(edg, type) { + const outEdges = $edges.filter((e) => e.source === edg.target && e.sourceHandle.includes(type)); - const inputIds = {}; - for (let i = 0; i < inputs.length; i++) { - const inp = inputs[i]; - inputIds[inp.id] = i; - } + let label; - const edgeIds = {}; - for (let i = 0; i < $edges.length; i++) { - const e = $edges[i]; - edgeIds[e.id] = i + 1; + const inNode = $nodes.find((n) => n.id === edg.source); + const outNode = $nodes.find((n) => n.id === edg.target); - 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"; - } - if (e.sourceHandle.includes("a")) { - edgeIds[e.id] = inputIds[source.id] + ":a"; - } + if (inNode && outNode) { + if (inNode.nodeType === "input" && outNode.nodeType === "filter") { + label = inputIdMap[inNode.id] + ":" + edg.sourceHandle[0]; + } else if (inNode.nodeType === "filter" && outNode.nodeType === "filter") { + label = labelIndex; + labelIndex++; + } else if (inNode.nodeType === "filter" && outNode.nodeType === "output") { + label = "out_" + type; + } else if (inNode.nodeType === "input" && outNode.nodeType === "output") { + label = "FILTERLESS_" + inputIdMap[inNode.id] + ":" + type; + } else { + label = "UNKNOWN"; } - if (target.nodeType === "output") { - const outType = e.targetHandle.includes("a") ? "aud_out" : "vid_out"; - edgeIds[e.id] = outType; + edgeIds[edg.id] = label; + } + + for (let e2 of outEdges) { + traverseEdges(e2, type); + } + } + + for (let inp of inputEdges) { + for (let t of ["v", "a"]) { + if (inp.sourceHandle.includes(t)) { + traverseEdges(inp, t); } } - } + } - for (let n of $nodes.filter((n) => n.nodeType == "filter")) { - let cmd = ""; + for (let n of $nodes.filter((n) => n.nodeType == "filter")) { + let cmd = { weight: 0, in: [], out: [], cmd: "" }; - const outs = $edges.filter((e) => e.source == n.id); - const ins = $edges.filter((e) => e.target == n.id); + const outs = $edges.filter((e) => e.source == n.id); + const ins = $edges.filter((e) => e.target == n.id); - if (outs.length == 0 && ins.length == 0) continue; + if (outs.length == 0 && ins.length == 0) continue; - for (let i of ins) { - const eid = edgeIds[i.id]; - cmd += `[${eid}]`; - } - cmd += makeFilterArgs(n.data); - for (let o of outs) { - const eid = edgeIds[o.id]; - cmd += `[${eid}]`; - } - filtergraph.push(cmd); - } + for (let i of ins) { + const eid = edgeIds[i.id]; + if (typeof eid == "string" && eid.includes(":")) cmd.weight = -1000; + cmd.in.push(eid); + } - finalCommand.push("ffmpeg"); + cmd.cmd = makeFilterArgs(n.data); - for (let inp of inputs) { - finalCommand.push("-i"); - finalCommand.push(inp.data.name); - } + for (let o of outs) { + const eid = edgeIds[o.id]; + if (typeof eid == "string" && eid.includes("out")) cmd.weight = 1000; + else cmd.weight = eid; + cmd.out.push(eid); + } - if (filtergraph.length > 0) { - const fg = '"' + filtergraph.join(";") + '"'; - hasVid = fg.includes(":v]"); - hasAud = fg.includes(":a]"); + filtergraph.push(cmd); + } - finalCommand.push("-filter_complex"); - finalCommand.push(fg); + filtergraph.sort((a, b) => { + return a.weight - b.weight; + }); - finalCommand.push("-map"); - if (hasAud) { - finalCommand.push('"[aud_out]"'); - } else { - finalCommand.push("0:a"); - } + filtergraph = filtergraph.map((c) => { + return c.in.map((i) => `[${i}]`).join("") + c.cmd + c.out.map((i) => `[${i}]`).join(""); + }); - finalCommand.push("-map"); - if (hasVid) { - finalCommand.push('"[vid_out]"'); - } else { - finalCommand.push("0:v"); - } - } + finalCommand.push("ffmpeg"); - for (let out of outputs) { - finalCommand.push(out.data.name); - } + for (let inp of inputs) { + finalCommand.push("-i"); + finalCommand.push(inp.data.name); + } - const entireCommand = finalCommand.join(" "); - return entireCommand; + let hasVid = false; + let hasAud = false; + + let mediaMaps = Object.values(edgeIds) + .map((eid) => { + if (String(eid).includes("FILTERLESS")) { + return eid.split("_")[1]; + } + return null; + }) + .filter((m) => m !== null); + + if (filtergraph.length > 0) { + let fg = `"${filtergraph.join(";")}"`; + + // this crazy thing replaces stuff like [1];[1] with a comma! + fg = fg.replaceAll(/(\[\d+\]);\1(?!\[)/g, ","); + + hasVid = fg.includes(":v]"); + hasAud = fg.includes(":a]"); + + finalCommand.push("-filter_complex", fg); + + if (hasAud) { + finalCommand.push("-map", '"[out_a]"'); + } + + if (hasVid) { + finalCommand.push("-map", '"[out_v]"'); + } + + for (let m of mediaMaps) { + finalCommand.push("-map", m); + } + } + + for (let out of outputs) { + finalCommand.push(out.data.name); + } + + return finalCommand.join(" "); }); - export const inputs = derived(nodes, ($nodes) => { - return $nodes.filter((n) => n.nodeType === "input").map((n) => n.data); + return $nodes.filter((n) => n.nodeType === "input").map((n) => n.data); }); nodes.subscribe(($nodes) => { - const isAuto = get(auto); - if (!isAuto) return; + const isAuto = get(auto); + if (!isAuto) return; - const outputNodes = $nodes.filter((n) => n.nodeType === "output"); - const inputNodes = $nodes.filter((n) => n.nodeType === "input"); - const filterNodes = $nodes.filter((n) => n.nodeType === "filter"); - const orderedNodes = [].concat(filterNodes, outputNodes).filter((n) => n != undefined); + const outputNodes = $nodes.filter((n) => n.nodeType === "output"); + const inputNodes = $nodes.filter((n) => n.nodeType === "input"); + const filterNodes = $nodes.filter((n) => n.nodeType === "filter"); + const orderedNodes = [].concat(filterNodes, outputNodes).filter((n) => n != undefined); - const filled = []; - let newEdges = []; + const filled = []; + let newEdges = []; - function connectNode(n1, rest) { - for (let i = 0; i < n1.data.outputs.length; i++) { - const edgeType = n1.data.outputs[i]; - for (let j = 0; j < rest.length; j++) { - let found = false; - const n2 = rest[j]; - for (let k = 0; k < n2.data.inputs.length; k++) { - const targetEdgeType = n2.data.inputs[k]; - if (edgeType === targetEdgeType && !filled.includes(n2.id + k)) { - newEdges.push({ - id: uuidv4(), - type: "default", - source: n1.id, - target: n2.id, - sourceHandle: edgeType + "_" + i, - targetHandle: edgeType + "_" + k, - }); - filled.push(n2.id + k); - found = true; - break; - } - } - if (found) break; - } - } - const nextNode = rest.shift(); - if (nextNode) { - connectNode(nextNode, rest); - } - } + function connectNode(n1, rest) { + for (let i = 0; i < n1.data.outputs.length; i++) { + const edgeType = n1.data.outputs[i]; + for (let j = 0; j < rest.length; j++) { + let found = false; + const n2 = rest[j]; + for (let k = 0; k < n2.data.inputs.length; k++) { + const targetEdgeType = n2.data.inputs[k]; + if (edgeType === targetEdgeType && !filled.includes(n2.id + k)) { + newEdges.push({ + id: uuidv4(), + type: "default", + source: n1.id, + target: n2.id, + sourceHandle: edgeType + "_" + i, + targetHandle: edgeType + "_" + k, + }); + filled.push(n2.id + k); + found = true; + break; + } + } + if (found) break; + } + } + const nextNode = rest.shift(); + if (nextNode) { + connectNode(nextNode, rest); + } + } - for (let inpNode of inputNodes) { - connectNode(inpNode, [...orderedNodes]); - } - // console.log("new", newEdges); - edges.set(newEdges); + for (let inpNode of inputNodes) { + connectNode(inpNode, [...orderedNodes]); + } + + edges.set(newEdges); }); export function addNode(_data, type) { const data = JSON.parse(JSON.stringify(_data)); - let ins = []; - let outs = []; + let ins = []; + let outs = []; - if (type === "input") { - outs = ["v", "a"]; - } else if (type === "output") { - ins = ["v", "a"]; - } else if (type === "filter") { - const [_ins, _outs] = data.type.split("->"); - ins = _ins.toLowerCase().split(""); - outs = _outs.toLowerCase().split(""); - if (data.params) { - data.params = data.params.map((p) => { - p.value = null; - if (p.default != null) p.value = p.default; - return p; - }); - } - } + if (type === "input") { + outs = ["v", "a"]; + } else if (type === "output") { + ins = ["v", "a"]; + } else if (type === "filter") { + const [_ins, _outs] = data.type.split("->"); + ins = _ins.toLowerCase().split(""); + outs = _outs.toLowerCase().split(""); + if (data.params) { + data.params = data.params.map((p) => { + p.value = null; + if (p.default != null) p.value = p.default; + return p; + }); + } + } data.nodeType = type; - data.inputs = ins; - data.outputs = outs; + data.inputs = ins; + data.outputs = outs; - let node = { - id: uuidv4(), - type: "ffmpeg", - data: data, - nodeType: type, - position: { x: 0, y: 0 }, - }; + let node = { + id: uuidv4(), + type: "ffmpeg", + data: data, + nodeType: type, + position: { x: 0, y: 0 }, + }; - nodes.update((_nodes) => { - _nodes.push(node); + nodes.update((_nodes) => { + _nodes.push(node); - const isAuto = get(auto); + const isAuto = get(auto); - if (isAuto) { - const w = 120; - const h = 50; - const margin = 50; - let prev = null; + if (isAuto) { + const w = 120; + const h = 50; + const margin = 50; + let prev = null; - for (let n of _nodes) { - if (n.nodeType === "input") { - n.position = { x: 0, y: prev ? prev.position.y + h + margin : 0 }; - prev = n; - } - } + for (let n of _nodes) { + if (n.nodeType === "input") { + n.position = { x: 0, y: prev ? prev.position.y + h + margin : 0 }; + prev = n; + } + } - for (let n of _nodes) { - if (n.nodeType === "filter") { + 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: -50 }; - prev = n; - } - } + n.position = { x: prev ? prev.position.x + _w + margin : 0, y: -50 }; + prev = n; + } + } - for (let n of _nodes) { - if (n.nodeType === "output") { + for (let n of _nodes) { + if (n.nodeType === "output") { let _w = prev && prev.width ? prev.width : w; - n.position = { x: prev ? prev.position.x + _w + margin : 0, y: 0 }; - } - } - } + n.position = { x: prev ? prev.position.x + _w + margin : 0, y: 0 }; + } + } + } if (node.nodeType === "filter") { selectedFilter.set(_nodes.length - 1); } - return _nodes; - }); + return _nodes; + }); } export function resetNodes() { @@ -262,17 +296,17 @@ export function resetNodes() { } export function removeNode(id) { - nodes.update((_nodes) => { - const index = _nodes.findIndex((n) => n.id === id); - _nodes.splice(index, 1); - return _nodes; - }); + nodes.update((_nodes) => { + const index = _nodes.findIndex((n) => n.id === id); + _nodes.splice(index, 1); + return _nodes; + }); } export function removeEdge(id) { edges.update((_edges) => { - const index = _edges.findIndex((e) => e.id === id); - _edges.splice(index, 1); - return _edges; + const index = _edges.findIndex((e) => e.id === id); + _edges.splice(index, 1); + return _edges; }); } diff --git a/tests/unit-tests/commandbuilder.js b/tests/unit-tests/commandbuilder.js index c75e142..111d4f6 100644 --- a/tests/unit-tests/commandbuilder.js +++ b/tests/unit-tests/commandbuilder.js @@ -1,6 +1,6 @@ import { expect, test, describe } from "vitest"; import { get } from "svelte/store"; -import { nodes, edges, addNode, resetNodes, makeFilterArgs, previewCommand } from "../../src/stores.js"; +import { addNode, resetNodes, makeFilterArgs, previewCommand } from "../../src/stores.js"; describe("Filter param builder", () => { test("No params", () => { @@ -58,7 +58,7 @@ describe("Command builder", () => { resetNodes(); addNode({ name: "filter", type: "V->V" }, "filter"); expect(get(previewCommand)).toBe( - `ffmpeg -i punch.mp4 -filter_complex "[0:v]filter[vid_out]" -map 0:a -map "[vid_out]" out.mp4` + `ffmpeg -i punch.mp4 -filter_complex "[0:v]filter[out_v]" -map "[out_v]" -map 0:a out.mp4` ); }); @@ -66,7 +66,36 @@ describe("Command builder", () => { resetNodes(); addNode({ name: "filter", type: "A->A" }, "filter"); expect(get(previewCommand)).toBe( - `ffmpeg -i punch.mp4 -filter_complex "[0:a]filter[aud_out]" -map "[aud_out]" -map 0:v out.mp4` + `ffmpeg -i punch.mp4 -filter_complex "[0:a]filter[out_a]" -map "[out_a]" -map 0:v out.mp4` + ); + }); + + test("One audio, one video filter", () => { + resetNodes(); + addNode({ name: "afilter", type: "A->A" }, "filter"); + addNode({ name: "vfilter", type: "V->V" }, "filter"); + expect(get(previewCommand)).toBe( + `ffmpeg -i punch.mp4 -filter_complex "[0:a]afilter[out_a];[0:v]vfilter[out_v]" -map "[out_a]" -map "[out_v]" out.mp4` + ); + }); + + test("video filter chain", () => { + resetNodes(); + addNode({ name: "vfilter", type: "V->V" }, "filter"); + addNode({ name: "vfilter2", type: "V->V" }, "filter"); + addNode({ name: "vfilter3", type: "V->V" }, "filter"); + expect(get(previewCommand)).toBe( + `ffmpeg -i punch.mp4 -filter_complex "[0:v]vfilter,vfilter2,vfilter3[out_v]" -map "[out_v]" -map 0:a out.mp4` + ); + }); + + test("One audio, two video filters", () => { + resetNodes(); + addNode({ name: "afilter", type: "A->A" }, "filter"); + addNode({ name: "vfilter", type: "V->V" }, "filter"); + addNode({ name: "vfilter2", type: "V->V" }, "filter"); + expect(get(previewCommand)).toBe( + `ffmpeg -i punch.mp4 -filter_complex "[0:v]vfilter[1];[0:a]afilter[out_a];[1]vfilter2[out_v]" -map "[out_a]" -map "[out_v]" out.mp4` ); }); });