From 870d16b4a1733e5765717f6642fd5a1db9eab810 Mon Sep 17 00:00:00 2001 From: Jonathan Cobb Date: Wed, 16 Dec 2020 17:15:59 -0500 Subject: [PATCH] add letterbox, refactor operations that can take one more many assets --- README.md | 10 ++- .../java/jvcl/model/operation/JOperation.java | 4 + .../jvcl/operation/LetterboxOperation.java | 24 ++++++ .../java/jvcl/operation/TrimOperation.java | 3 +- .../jvcl/operation/exec/LetterboxExec.java | 83 +++++++++++++++++++ .../java/jvcl/operation/exec/ScaleExec.java | 47 ++--------- .../exec/SingleOrMultiSourceExecBase.java | 59 +++++++++++++ .../java/jvcl/operation/exec/TrimExec.java | 48 ++--------- src/main/java/jvcl/service/Toolbox.java | 5 +- src/test/java/javicle/test/BasicTest.java | 3 +- src/test/resources/tests/test_letterbox.jvcl | 23 +++++ src/test/resources/tests/test_scale.jvcl | 6 +- 12 files changed, 230 insertions(+), 85 deletions(-) create mode 100644 src/main/java/jvcl/operation/LetterboxOperation.java create mode 100644 src/main/java/jvcl/operation/exec/LetterboxExec.java create mode 100644 src/main/java/jvcl/operation/exec/SingleOrMultiSourceExecBase.java create mode 100644 src/test/resources/tests/test_letterbox.jvcl diff --git a/README.md b/README.md index a15b50f..341d917 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ The above would set the `start` value to ten seconds before the end of `someAsse Today, JVCL supports these operations: ### scale -Scale a video asset from one size to another +Scale a video asset from one size to another. Scaling can be proportional or anamorphic ### split Split an audio/video asset into multiple assets of equal time lengths @@ -254,6 +254,14 @@ Here is a complex example using multiple assets and operations. "upscale": "8", // upscale factor. upscaling the image results in a smoother pan, but a longer encode, default is 8 "width": "1024", // width of output video "height": "768" // height of output video + }, + { + "operation": "letterbox", // name of the operation + "creates": "boxed1", // asset it creates + "source": "ken1", // source asset + "width": "source.width * 1.5", // make it wider + "height": "source.height * 0.9", // and shorter + "color": "AliceBlue" // default is black. can be a hex value (0xff0000 for red) or a color name from here: https://ffmpeg.org/ffmpeg-utils.html#color-syntax } ] } diff --git a/src/main/java/jvcl/model/operation/JOperation.java b/src/main/java/jvcl/model/operation/JOperation.java index 789f3d6..0399961 100644 --- a/src/main/java/jvcl/model/operation/JOperation.java +++ b/src/main/java/jvcl/model/operation/JOperation.java @@ -17,6 +17,8 @@ import static jvcl.service.json.JOperationFactory.getOperationExecClass; import static org.cobbzilla.util.daemon.ZillaRuntime.hashOf; import static org.cobbzilla.util.json.JsonUtil.json; import static org.cobbzilla.util.reflect.ReflectionUtil.instantiate; +import static org.cobbzilla.util.security.ShaUtil.sha256_hex; +import static org.cobbzilla.util.string.StringUtil.safeShellArg; @NoArgsConstructor @Accessors(chain=true) @Slf4j @JsonTypeInfo( @@ -41,4 +43,6 @@ public abstract class JOperation { return (ExecBase) execMap.computeIfAbsent(getClass(), c -> instantiate(getOperationExecClass(getClass()))); } + public String shortString() { return safeShellArg(operation+"_"+sha256_hex(json(this))); } + } diff --git a/src/main/java/jvcl/operation/LetterboxOperation.java b/src/main/java/jvcl/operation/LetterboxOperation.java new file mode 100644 index 0000000..620ffe4 --- /dev/null +++ b/src/main/java/jvcl/operation/LetterboxOperation.java @@ -0,0 +1,24 @@ +package jvcl.operation; + +import jvcl.model.operation.JSingleSourceOperation; +import lombok.Getter; +import lombok.Setter; +import org.cobbzilla.util.javascript.JsEngine; + +import java.util.Map; + +import static org.cobbzilla.util.daemon.ZillaRuntime.empty; + +public class LetterboxOperation extends JSingleSourceOperation implements HasWidthAndHeight { + + @Getter @Setter private String width; + @Getter @Setter private String height; + + @Getter @Setter private String color; + public boolean hasColor () { return !empty(color); } + + public String shortString(Map ctx, JsEngine js) { + return "letterbox_"+color+"_"+getWidth(ctx, js)+"x"+getHeight(ctx, js); + } + +} diff --git a/src/main/java/jvcl/operation/TrimOperation.java b/src/main/java/jvcl/operation/TrimOperation.java index 348aa77..cf4bbe7 100644 --- a/src/main/java/jvcl/operation/TrimOperation.java +++ b/src/main/java/jvcl/operation/TrimOperation.java @@ -12,6 +12,7 @@ import java.util.Map; import static java.math.BigDecimal.ZERO; import static jvcl.service.Toolbox.evalBig; import static org.cobbzilla.util.daemon.ZillaRuntime.empty; +import static org.cobbzilla.util.string.StringUtil.safeShellArg; @Slf4j public class TrimOperation extends JSingleSourceOperation { @@ -23,7 +24,7 @@ public class TrimOperation extends JSingleSourceOperation { public boolean hasEnd() { return !empty(end); } public BigDecimal getEndTime(Map ctx, JsEngine js) { return evalBig(end, ctx, js); } - public String shortString() { return "trim_"+getStart()+(hasEnd() ? "_"+getEnd() : ""); } + @Override public String shortString() { return safeShellArg("trim_" + getStart() + (hasEnd() ? "_" + getEnd() : "")); } public String toString() { return getSource()+"_"+getStart()+(hasEnd() ? "_"+getEnd() : ""); } } diff --git a/src/main/java/jvcl/operation/exec/LetterboxExec.java b/src/main/java/jvcl/operation/exec/LetterboxExec.java new file mode 100644 index 0000000..3c7e2f2 --- /dev/null +++ b/src/main/java/jvcl/operation/exec/LetterboxExec.java @@ -0,0 +1,83 @@ +package jvcl.operation.exec; + +import jvcl.model.JAsset; +import jvcl.model.JFileExtension; +import jvcl.model.operation.JSingleOperationContext; +import jvcl.operation.LetterboxOperation; +import jvcl.service.AssetManager; +import jvcl.service.Toolbox; +import lombok.extern.slf4j.Slf4j; +import org.cobbzilla.util.javascript.StandardJsEngine; + +import java.util.HashMap; +import java.util.Map; + +import static org.cobbzilla.util.daemon.ZillaRuntime.die; +import static org.cobbzilla.util.string.StringUtil.safeShellArg; +import static org.cobbzilla.util.system.CommandShell.execScript; + +@Slf4j +public class LetterboxExec extends SingleOrMultiSourceExecBase { + + public static final String LETTERBOX_TEMPLATE + = "{{ffmpeg}} -i {{{source.path}}} -filter_complex \"" + + "pad=" + + "width={{width}}:" + + "height={{height}}:" + + "x=({{width}}-iw*min({{width}}/iw\\,{{height}}/ih))/2:" + + "y=({{height}}-ih*min({{width}}/iw\\,{{height}}/ih))/2:" + + "color={{{color}}}" + + "\" -y {{{output.path}}}"; + + public static final String DEFAULT_LETTERBOX_COLOR = "black"; + + @Override public void operate(LetterboxOperation op, Toolbox toolbox, AssetManager assetManager) { + + final JSingleOperationContext opCtx = op.getSingleInputContext(assetManager); + final JAsset source = opCtx.source; + final JAsset output = opCtx.output; + final JFileExtension formatType = opCtx.formatType; + + final StandardJsEngine js = toolbox.getJs(); + final Map ctx = new HashMap<>(); + ctx.put("ffmpeg", toolbox.getFfmpeg()); + ctx.put("source", source); + + if (!op.hasWidth() || !op.hasHeight()) { + die("operate: both width and height must be set"); + } + ctx.put("width", op.getWidth(ctx, js).intValue()); + ctx.put("height", op.getHeight(ctx, js).intValue()); + + if (op.hasColor()) { + ctx.put("color", safeShellArg(op.getColor())); + } else { + ctx.put("color", DEFAULT_LETTERBOX_COLOR); + } + + operate(op, toolbox, assetManager, source, output, formatType, ctx); + } + + @Override protected void process(Map ctx, + LetterboxOperation op, + JAsset source, + JAsset output, + JAsset subOutput, + Toolbox toolbox, + AssetManager assetManager) { + + ctx.put("source", source); + ctx.put("output", subOutput); + final String script = renderScript(toolbox, ctx, LETTERBOX_TEMPLATE); + + log.debug("operate: running script: "+script); + final String scriptOutput = execScript(script); + log.debug("operate: command output: "+scriptOutput); + if (output == subOutput) { + assetManager.addOperationAsset(output); + } else { + assetManager.addOperationAssetSlice(output, subOutput); + } + } + +} diff --git a/src/main/java/jvcl/operation/exec/ScaleExec.java b/src/main/java/jvcl/operation/exec/ScaleExec.java index 6118e4e..8ccf5a9 100644 --- a/src/main/java/jvcl/operation/exec/ScaleExec.java +++ b/src/main/java/jvcl/operation/exec/ScaleExec.java @@ -9,17 +9,14 @@ import jvcl.service.Toolbox; import lombok.extern.slf4j.Slf4j; import org.cobbzilla.util.javascript.StandardJsEngine; -import java.io.File; import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; -import static org.cobbzilla.util.daemon.ZillaRuntime.die; -import static org.cobbzilla.util.io.FileUtil.*; import static org.cobbzilla.util.system.CommandShell.execScript; @Slf4j -public class ScaleExec extends ExecBase { +public class ScaleExec extends SingleOrMultiSourceExecBase { public static final String SCALE_TEMPLATE = "{{ffmpeg}} -i {{{source.path}}} -filter_complex \"" @@ -45,42 +42,16 @@ public class ScaleExec extends ExecBase { op.setProportionalWidthAndHeight(ctx, js, source); } - if (source.hasList()) { - if (output.hasDest()) { - if (!output.destIsDirectory()) die("operate: dest is not a directory: "+output.getDest()); - } - assetManager.addOperationArrayAsset(output); - for (JAsset asset : source.getList()) { - final JAsset subOutput = new JAsset(output); - final File defaultOutfile = assetManager.assetPath(op, asset, formatType); - final File outfile; - if (output.hasDest()) { - outfile = new File(output.destDirectory(), basename(appendToFileNameBeforeExt(asset.getPath(), "_"+op.shortString(ctx, js)))); - if (outfile.exists()) { - log.info("operate: dest exists: "+abs(outfile)); - return; - } - } else { - outfile = defaultOutfile; - } - subOutput.setPath(abs(outfile)); - scale(ctx, asset, output, subOutput, toolbox, assetManager); - } - } else { - final File defaultOutfile = assetManager.assetPath(op, source, formatType); - final File path = resolveOutputPath(output, defaultOutfile); - if (path == null) return; - output.setPath(abs(path)); - scale(ctx, source, output, output, toolbox, assetManager); - } + operate(op, toolbox, assetManager, source, output, formatType, ctx); } - private void scale(Map ctx, - JAsset source, - JAsset output, - JAsset subOutput, - Toolbox toolbox, - AssetManager assetManager) { + @Override protected void process(Map ctx, + ScaleOperation op, + JAsset source, + JAsset output, + JAsset subOutput, + Toolbox toolbox, + AssetManager assetManager) { ctx.put("source", source); ctx.put("output", subOutput); diff --git a/src/main/java/jvcl/operation/exec/SingleOrMultiSourceExecBase.java b/src/main/java/jvcl/operation/exec/SingleOrMultiSourceExecBase.java new file mode 100644 index 0000000..d5c470b --- /dev/null +++ b/src/main/java/jvcl/operation/exec/SingleOrMultiSourceExecBase.java @@ -0,0 +1,59 @@ +package jvcl.operation.exec; + +import jvcl.model.JAsset; +import jvcl.model.JFileExtension; +import jvcl.model.operation.JOperation; +import jvcl.service.AssetManager; +import jvcl.service.Toolbox; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.util.Map; + +import static org.cobbzilla.util.daemon.ZillaRuntime.die; +import static org.cobbzilla.util.io.FileUtil.*; + +@Slf4j +public abstract class SingleOrMultiSourceExecBase extends ExecBase { + + protected void operate(OP op, Toolbox toolbox, AssetManager assetManager, JAsset source, JAsset output, JFileExtension formatType, Map ctx) { + if (source.hasList()) { + if (output.hasDest()) { + if (!output.destIsDirectory()) die("operate: dest is not a directory: "+output.getDest()); + } + assetManager.addOperationArrayAsset(output); + for (JAsset asset : source.getList()) { + final JAsset subOutput = new JAsset(output); + final File defaultOutfile = assetManager.assetPath(op, asset, formatType); + final File outfile; + if (output.hasDest()) { + outfile = new File(output.destDirectory(), basename(appendToFileNameBeforeExt(asset.getPath(), "_"+op.shortString()))); + if (outfile.exists()) { + log.info("operate: dest exists: "+abs(outfile)); + return; + } + } else { + outfile = defaultOutfile; + } + subOutput.setPath(abs(outfile)); + process(ctx, op, asset, output, subOutput, toolbox, assetManager); + } + } else { + final File defaultOutfile = assetManager.assetPath(op, source, formatType); + final File path = resolveOutputPath(output, defaultOutfile); + if (path == null) return; + output.setPath(abs(path)); + process(ctx, op, source, output, output, toolbox, assetManager); + } + } + + protected abstract void process(Map ctx, + OP op, + JAsset source, + JAsset output, + JAsset asset, + Toolbox toolbox, + AssetManager assetManager); + + +} diff --git a/src/main/java/jvcl/operation/exec/TrimExec.java b/src/main/java/jvcl/operation/exec/TrimExec.java index 178f84a..27b83f1 100644 --- a/src/main/java/jvcl/operation/exec/TrimExec.java +++ b/src/main/java/jvcl/operation/exec/TrimExec.java @@ -9,17 +9,14 @@ import jvcl.service.Toolbox; import lombok.extern.slf4j.Slf4j; import org.cobbzilla.util.javascript.StandardJsEngine; -import java.io.File; import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; -import static org.cobbzilla.util.daemon.ZillaRuntime.die; -import static org.cobbzilla.util.io.FileUtil.*; import static org.cobbzilla.util.system.CommandShell.execScript; @Slf4j -public class TrimExec extends ExecBase { +public class TrimExec extends SingleOrMultiSourceExecBase { public static final String TRIM_TEMPLATE = "{{ffmpeg}} -i {{{source.path}}} " + @@ -37,43 +34,16 @@ public class TrimExec extends ExecBase { final Map ctx = new HashMap<>(); ctx.put("ffmpeg", toolbox.getFfmpeg()); - if (source.hasList()) { - if (output.hasDest()) { - if (!output.destIsDirectory()) die("operate: dest is not a directory: "+output.getDest()); - } - assetManager.addOperationArrayAsset(output); - for (JAsset asset : source.getList()) { - final JAsset subOutput = new JAsset(output); - final File defaultOutfile = assetManager.assetPath(op, asset, formatType); - final File outfile; - if (output.hasDest()) { - outfile = new File(output.destDirectory(), basename(appendToFileNameBeforeExt(asset.getPath(), "_"+op.shortString()))); - if (outfile.exists()) { - log.info("operate: dest exists: "+abs(outfile)); - return; - } - } else { - outfile = defaultOutfile; - } - subOutput.setPath(abs(outfile)); - trim(ctx, op, asset, output, subOutput, toolbox, assetManager); - } - } else { - final File defaultOutfile = assetManager.assetPath(op, source, formatType); - final File path = resolveOutputPath(output, defaultOutfile); - if (path == null) return; - output.setPath(abs(path)); - trim(ctx, op, source, output, output, toolbox, assetManager); - } + operate(op, toolbox, assetManager, source, output, formatType, ctx); } - private void trim(Map ctx, - TrimOperation op, - JAsset source, - JAsset output, - JAsset subOutput, - Toolbox toolbox, - AssetManager assetManager) { + @Override protected void process(Map ctx, + TrimOperation op, + JAsset source, + JAsset output, + JAsset subOutput, + Toolbox toolbox, + AssetManager assetManager) { ctx.put("source", source); ctx.put("output", subOutput); diff --git a/src/main/java/jvcl/service/Toolbox.java b/src/main/java/jvcl/service/Toolbox.java index d5b8e4c..967de43 100644 --- a/src/main/java/jvcl/service/Toolbox.java +++ b/src/main/java/jvcl/service/Toolbox.java @@ -22,6 +22,7 @@ import static java.math.RoundingMode.HALF_EVEN; import static org.cobbzilla.util.daemon.ZillaRuntime.*; import static org.cobbzilla.util.io.FileUtil.*; import static org.cobbzilla.util.json.JsonUtil.*; +import static org.cobbzilla.util.string.StringUtil.safeShellArg; import static org.cobbzilla.util.system.CommandShell.execScript; @Slf4j @@ -84,10 +85,10 @@ public class Toolbox { } @Getter(lazy=true) private final String ffmpeg = initFfmpeg(); - private String initFfmpeg() { return loadPath("ffmpeg"); } + private String initFfmpeg() { return safeShellArg(loadPath("ffmpeg")); } @Getter(lazy=true) private final String mediainfo = initMediainfo(); - private String initMediainfo() { return loadPath("mediainfo"); } + private String initMediainfo() { return safeShellArg(loadPath("mediainfo")); } private static String loadPath(String p) { try { diff --git a/src/test/java/javicle/test/BasicTest.java b/src/test/java/javicle/test/BasicTest.java index 4910fcf..33edc2e 100644 --- a/src/test/java/javicle/test/BasicTest.java +++ b/src/test/java/javicle/test/BasicTest.java @@ -18,11 +18,12 @@ import static org.junit.Assert.fail; @Slf4j public class BasicTest { - @Test public void testSplitConcatTrimScale () { + @Test public void testSplitConcatTrimScaleLetterbox () { runSpec("tests/test_split.jvcl"); runSpec("tests/test_concat.jvcl"); runSpec("tests/test_trim.jvcl"); runSpec("tests/test_scale.jvcl"); + runSpec("tests/test_letterbox.jvcl"); } @Test public void testOverlay() { runSpec("tests/test_overlay.jvcl"); } diff --git a/src/test/resources/tests/test_letterbox.jvcl b/src/test/resources/tests/test_letterbox.jvcl new file mode 100644 index 0000000..fef0595 --- /dev/null +++ b/src/test/resources/tests/test_letterbox.jvcl @@ -0,0 +1,23 @@ +{ + "assets": [ + { "name": "vid1_splits", "path": "src/test/resources/outputs/vid1_splits_*.mp4" } + ], + "operations": [ + { + "operation": "letterbox", // name of the operation + "creates": "boxed_wide", + "source": "vid1_splits[2]", // box this source asset + "width": "source.width * 2", // width of output asset. if omitted and height is present, width will be proportional + "height": "source.height", // height of output asset. if omitted and width is present, height will be proportional + "color": "AliceBlue" + }, + { + "operation": "letterbox", // name of the operation + "creates": "boxed_tall", + "source": "vid1_splits[2]", // box this source assets + "width": "source.width", // width of output asset. if omitted and height is present, width will be proportional + "height": "source.height * 2", // height of output asset. if omitted and width is present, height will be proportional + "color": "DarkCyan" + } + ] +} diff --git a/src/test/resources/tests/test_scale.jvcl b/src/test/resources/tests/test_scale.jvcl index dc55a26..6a4f18d 100644 --- a/src/test/resources/tests/test_scale.jvcl +++ b/src/test/resources/tests/test_scale.jvcl @@ -4,16 +4,16 @@ ], "operations": [ { - "operation": "scale", // name of the operation + "operation": "scale", // name of the operation "creates": "scaled_test1", - "source": "vid1_splits[3]", // trim these source assets + "source": "vid1_splits[3]", // scale this source asset "width": "1024", // width of scaled asset. if omitted and height is present, width will be proportional "height": "768" // height of scaled asset. if omitted and width is present, height will be proportional }, { "operation": "scale", // name of the operation "creates": "scaled_small", // asset it creates - "source": "vid1_splits[3]", // source asset + "source": "vid1_splits[3]", // scale this source asset "factor": "0.5" // scale factor. if factor is set, width and height are ignored. } ]