From 77b48cc81d0570e2d1a225ec2e71fe7d2c5155ea Mon Sep 17 00:00:00 2001 From: Jonathan Cobb Date: Wed, 16 Dec 2020 16:02:48 -0500 Subject: [PATCH] add scale operation --- README.md | 34 +++++-- src/main/java/jvcl/model/JAsset.java | 9 +- .../jvcl/operation/HasWidthAndHeight.java | 48 +++++++++ .../java/jvcl/operation/OverlayOperation.java | 7 +- .../java/jvcl/operation/ScaleOperation.java | 27 +++++ .../java/jvcl/operation/exec/OverlayExec.java | 20 +--- .../java/jvcl/operation/exec/ScaleExec.java | 99 +++++++++++++++++++ .../java/jvcl/operation/exec/SplitExec.java | 3 +- .../java/jvcl/operation/exec/TrimExec.java | 14 +-- src/test/java/javicle/test/BasicTest.java | 3 +- src/test/resources/tests/test_scale.jvcl | 20 ++++ 11 files changed, 243 insertions(+), 41 deletions(-) create mode 100644 src/main/java/jvcl/operation/HasWidthAndHeight.java create mode 100644 src/main/java/jvcl/operation/ScaleOperation.java create mode 100644 src/main/java/jvcl/operation/exec/ScaleExec.java create mode 100644 src/test/resources/tests/test_scale.jvcl diff --git a/README.md b/README.md index c09ed75..a15b50f 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,11 @@ The above would set the `start` value to ten seconds before the end of `someAsse ### Supported Operations Today, JVCL supports these operations: +### scale +Scale a video asset from one size to another + ### split -Split an audio/video asset into multiple assets +Split an audio/video asset into multiple assets of equal time lengths ### concat Concatenate audio/video assets together into one asset @@ -136,9 +139,6 @@ Concatenate audio/video assets together into one asset ### trim Trim audio/video; crop a section of an asset, becomes a new asset -### scale -Scale a video asset from one size to another - ### overlay Overlay one asset onto another @@ -148,9 +148,6 @@ For transforming still images into video via a fade-pan (aka Ken Burns) effect ### letterbox Transform a video in one size to another size using black letterboxes on the sides or top/bottom. Handy for embedding mobile videos into other screen formats -### split-silence -Split an audio file according to silence - # Complex Example Here is a complex example using multiple assets and operations. @@ -184,6 +181,19 @@ Here is a complex example using multiple assets and operations. } ], "operations": [ + { + "operation": "scale", // name of the operation + "creates": "vid2_scaled", // asset it creates + "source": "vid2", // 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": "vid2_big", // asset it creates + "source": "vid2", // source asset + "factor": "2.2" // scale factor. if factor is set, width and height are ignored. + }, { "operation": "split", // name of the operation "creates": "vid1_split_%", // assets it creates, the '%' will be replaced with a counter @@ -205,6 +215,16 @@ Here is a complex example using multiple assets and operations. "creates": "combined_vid", // the asset it creates, can be referenced later "source": ["vid1", "vid2"] // operation-specific: this says, concatenate these named assets }, + { + "operation": "trim", // name of the operation + "creates": { // create multiple files, will be prefixed with `name`, store them in `dest` + "name": "vid1_trims", + "dest": "src/test/resources/outputs/trims/" + }, + "source": "vid1_split", // trim these source assets + "start": "1", // cropped region starts here, default is zero + "end": "6" // cropped region ends here, default is end of video + }, { "operation": "overlay", // name of the operation "creates": "overlay1", // asset it creates diff --git a/src/main/java/jvcl/model/JAsset.java b/src/main/java/jvcl/model/JAsset.java index cc7985c..bcb89a5 100644 --- a/src/main/java/jvcl/model/JAsset.java +++ b/src/main/java/jvcl/model/JAsset.java @@ -186,13 +186,20 @@ public class JAsset implements JsObjectView { final File sourcePath; if (hasDest()) { if (destIsDirectory()) { - sourcePath = new File(getDest(), basename(getName())); + sourcePath = new File(getDest(), basename(getPath())); } else { sourcePath = new File(getDest()); } } else { sourcePath = assetManager.sourcePath(getName()); } + + if (sourcePath.exists()) { + setOriginalPath(path); + setPath(abs(sourcePath)); + return this; + } + if (path.startsWith(PREFIX_CLASSPATH)) { // it's a classpath resource final String resource = path.substring(PREFIX_CLASSPATH.length()); diff --git a/src/main/java/jvcl/operation/HasWidthAndHeight.java b/src/main/java/jvcl/operation/HasWidthAndHeight.java new file mode 100644 index 0000000..337b22c --- /dev/null +++ b/src/main/java/jvcl/operation/HasWidthAndHeight.java @@ -0,0 +1,48 @@ +package jvcl.operation; + +import jvcl.model.JAsset; +import org.cobbzilla.util.javascript.JsEngine; +import org.cobbzilla.util.javascript.StandardJsEngine; + +import java.math.BigDecimal; +import java.util.Map; + +import static jvcl.service.Toolbox.divideBig; +import static jvcl.service.Toolbox.evalBig; +import static org.cobbzilla.util.daemon.ZillaRuntime.empty; + +public interface HasWidthAndHeight { + + String getWidth (); + default boolean hasWidth () { return !empty(getWidth()); } + + String getHeight (); + default boolean hasHeight () { return !empty(getHeight()); } + + default BigDecimal getWidth(Map ctx, JsEngine js) { return evalBig(getWidth(), ctx, js); } + default BigDecimal getHeight(Map ctx, JsEngine js) { return evalBig(getHeight(), ctx, js); } + + default void setProportionalWidthAndHeight(Map ctx, + StandardJsEngine js, + JAsset asset) { + if (hasWidth()) { + final BigDecimal width = getWidth(ctx, js); + ctx.put("width", width.intValue()); + if (!hasHeight()) { + final BigDecimal aspectRatio = asset.aspectRatio(); + final int height = divideBig(width, aspectRatio).intValue(); + ctx.put("height", height); + } + } + if (hasHeight()) { + final BigDecimal height = getHeight(ctx, js); + ctx.put("height", height.intValue()); + if (!hasWidth()) { + final BigDecimal aspectRatio = asset.aspectRatio(); + final int width = height.multiply(aspectRatio).intValue(); + ctx.put("width", width); + } + } + } + +} diff --git a/src/main/java/jvcl/operation/OverlayOperation.java b/src/main/java/jvcl/operation/OverlayOperation.java index 1addd44..734204a 100644 --- a/src/main/java/jvcl/operation/OverlayOperation.java +++ b/src/main/java/jvcl/operation/OverlayOperation.java @@ -28,7 +28,7 @@ public class OverlayOperation extends JSingleSourceOperation { return evalBig(end, ctx, js); } - public static class OverlayConfig { + public static class OverlayConfig implements HasWidthAndHeight { @Getter @Setter private String source; @Getter @Setter private String start; @@ -43,12 +43,7 @@ public class OverlayOperation extends JSingleSourceOperation { } @Getter @Setter private String width; - public boolean hasWidth () { return !empty(width); } - public BigDecimal getWidth(Map ctx, JsEngine js) { return evalBig(width, ctx, js); } - @Getter @Setter private String height; - public boolean hasHeight () { return !empty(height); } - public BigDecimal getHeight(Map ctx, JsEngine js) { return evalBig(height, ctx, js); } @Getter @Setter private String x; public boolean hasX () { return !empty(x); } diff --git a/src/main/java/jvcl/operation/ScaleOperation.java b/src/main/java/jvcl/operation/ScaleOperation.java new file mode 100644 index 0000000..d266275 --- /dev/null +++ b/src/main/java/jvcl/operation/ScaleOperation.java @@ -0,0 +1,27 @@ +package jvcl.operation; + +import jvcl.model.operation.JSingleSourceOperation; +import lombok.Getter; +import lombok.Setter; +import org.cobbzilla.util.javascript.JsEngine; + +import java.math.BigDecimal; +import java.util.Map; + +import static jvcl.service.Toolbox.evalBig; +import static org.cobbzilla.util.daemon.ZillaRuntime.empty; + +public class ScaleOperation extends JSingleSourceOperation implements HasWidthAndHeight { + + @Getter @Setter private String factor; + public boolean hasFactor () { return !empty(factor); } + public BigDecimal getFactor(Map ctx, JsEngine js) { return evalBig(factor, ctx, js); } + + @Getter @Setter private String width; + @Getter @Setter private String height; + + public String shortString(Map ctx, JsEngine js) { + return "scaled_"+(hasFactor() ? getFactor(ctx, js)+"x" : getWidth(ctx, js)+"x"+getHeight(ctx, js)); + } + +} diff --git a/src/main/java/jvcl/operation/exec/OverlayExec.java b/src/main/java/jvcl/operation/exec/OverlayExec.java index 7fa4944..8e56717 100644 --- a/src/main/java/jvcl/operation/exec/OverlayExec.java +++ b/src/main/java/jvcl/operation/exec/OverlayExec.java @@ -14,7 +14,6 @@ import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; -import static jvcl.service.Toolbox.divideBig; import static org.cobbzilla.util.io.FileUtil.abs; import static org.cobbzilla.util.system.CommandShell.execScript; @@ -55,24 +54,7 @@ public class OverlayExec extends ExecBase { ctx.put("overlayFilterConfig", overlayFilter); ctx.put("output", output); - if (overlay.hasWidth()) { - final BigDecimal width = overlay.getWidth(ctx, js); - ctx.put("width", width.intValue()); - if (!overlay.hasHeight()) { - final BigDecimal aspectRatio = overlaySource.aspectRatio(); - final int height = divideBig(width, aspectRatio).intValue(); - ctx.put("height", height); - } - } - if (overlay.hasHeight()) { - final BigDecimal height = overlay.getHeight(ctx, js); - ctx.put("height", height.intValue()); - if (!overlay.hasWidth()) { - final BigDecimal aspectRatio = overlaySource.aspectRatio(); - final int width = height.multiply(aspectRatio).intValue(); - ctx.put("width", width); - } - } + overlay.setProportionalWidthAndHeight(ctx, js, overlaySource); final String script = renderScript(toolbox, ctx, OVERLAY_TEMPLATE); diff --git a/src/main/java/jvcl/operation/exec/ScaleExec.java b/src/main/java/jvcl/operation/exec/ScaleExec.java new file mode 100644 index 0000000..6118e4e --- /dev/null +++ b/src/main/java/jvcl/operation/exec/ScaleExec.java @@ -0,0 +1,99 @@ +package jvcl.operation.exec; + +import jvcl.model.JAsset; +import jvcl.model.JFileExtension; +import jvcl.model.operation.JSingleOperationContext; +import jvcl.operation.ScaleOperation; +import jvcl.service.AssetManager; +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 static final String SCALE_TEMPLATE + = "{{ffmpeg}} -i {{{source.path}}} -filter_complex \"" + + "scale={{width}}x{{height}}" + + "\" -y {{{output.path}}}"; + + @Override public void operate(ScaleOperation 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()); + + if (op.hasFactor()) { + final BigDecimal factor = op.getFactor(ctx, js); + ctx.put("width", factor.multiply(source.getWidth()).intValue()); + ctx.put("height", factor.multiply(source.getHeight()).intValue()); + } else { + 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); + } + } + + private void scale(Map ctx, + JAsset source, + JAsset output, + JAsset subOutput, + Toolbox toolbox, + AssetManager assetManager) { + + ctx.put("source", source); + ctx.put("output", subOutput); + final String script = renderScript(toolbox, ctx, SCALE_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/SplitExec.java b/src/main/java/jvcl/operation/exec/SplitExec.java index 43bbcf6..1dc85bd 100644 --- a/src/main/java/jvcl/operation/exec/SplitExec.java +++ b/src/main/java/jvcl/operation/exec/SplitExec.java @@ -37,6 +37,7 @@ public class SplitExec extends ExecBase { ctx.put("ffmpeg", toolbox.getFfmpeg()); ctx.put("source", source); + assetManager.addOperationArrayAsset(output); final BigDecimal incr = op.getIntervalIncr(ctx, js); final BigDecimal endTime = op.getEndTime(source, ctx, js); for (BigDecimal i = op.getStartTime(ctx, js); @@ -61,7 +62,7 @@ public class SplitExec extends ExecBase { if (outfile.exists()) { log.info("operate: outfile exists, not re-creating: "+abs(outfile)); - return; + continue; } else { mkdirOrDie(outfile.getParentFile()); } diff --git a/src/main/java/jvcl/operation/exec/TrimExec.java b/src/main/java/jvcl/operation/exec/TrimExec.java index 92443fa..178f84a 100644 --- a/src/main/java/jvcl/operation/exec/TrimExec.java +++ b/src/main/java/jvcl/operation/exec/TrimExec.java @@ -34,6 +34,9 @@ public class TrimExec extends ExecBase { final JAsset output = opCtx.output; final JFileExtension formatType = opCtx.formatType; + 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()); @@ -53,30 +56,29 @@ public class TrimExec extends ExecBase { outfile = defaultOutfile; } subOutput.setPath(abs(outfile)); - trim(op, asset, output, subOutput, toolbox, assetManager); + 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(op, source, output, output, toolbox, assetManager); + trim(ctx, op, source, output, output, toolbox, assetManager); } } - private void trim(TrimOperation op, + private void trim(Map ctx, + TrimOperation op, JAsset source, JAsset output, JAsset subOutput, Toolbox toolbox, AssetManager assetManager) { - final StandardJsEngine js = toolbox.getJs(); - final Map ctx = new HashMap<>(); - ctx.put("ffmpeg", toolbox.getFfmpeg()); ctx.put("source", source); ctx.put("output", subOutput); + final StandardJsEngine js = toolbox.getJs(); final BigDecimal startTime = op.getStartTime(ctx, js); ctx.put("startSeconds", startTime); if (op.hasEnd()) ctx.put("interval", op.getEndTime(ctx, js).subtract(startTime)); diff --git a/src/test/java/javicle/test/BasicTest.java b/src/test/java/javicle/test/BasicTest.java index 0829f29..4910fcf 100644 --- a/src/test/java/javicle/test/BasicTest.java +++ b/src/test/java/javicle/test/BasicTest.java @@ -18,10 +18,11 @@ import static org.junit.Assert.fail; @Slf4j public class BasicTest { - @Test public void testSplitConcatAndTrim () { + @Test public void testSplitConcatTrimScale () { runSpec("tests/test_split.jvcl"); runSpec("tests/test_concat.jvcl"); runSpec("tests/test_trim.jvcl"); + runSpec("tests/test_scale.jvcl"); } @Test public void testOverlay() { runSpec("tests/test_overlay.jvcl"); } diff --git a/src/test/resources/tests/test_scale.jvcl b/src/test/resources/tests/test_scale.jvcl new file mode 100644 index 0000000..dc55a26 --- /dev/null +++ b/src/test/resources/tests/test_scale.jvcl @@ -0,0 +1,20 @@ +{ + "assets": [ + { "name": "vid1_splits", "path": "src/test/resources/outputs/vid1_splits_*.mp4" } + ], + "operations": [ + { + "operation": "scale", // name of the operation + "creates": "scaled_test1", + "source": "vid1_splits[3]", // trim these source assets + "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 + "factor": "0.5" // scale factor. if factor is set, width and height are ignored. + } + ] +}