diff --git a/src/main/java/jvcl/model/JAsset.java b/src/main/java/jvcl/model/JAsset.java index e047880..e865c3b 100644 --- a/src/main/java/jvcl/model/JAsset.java +++ b/src/main/java/jvcl/model/JAsset.java @@ -21,6 +21,7 @@ import java.util.*; import java.util.regex.Pattern; import java.util.stream.Collectors; +import static java.math.RoundingMode.HALF_EVEN; import static java.util.Comparator.comparing; import static org.cobbzilla.util.daemon.ZillaRuntime.*; import static org.cobbzilla.util.http.HttpSchemes.isHttpOrHttps; @@ -31,7 +32,7 @@ import static org.cobbzilla.util.reflect.ReflectionUtil.copy; import static org.cobbzilla.util.system.CommandShell.execScript; @NoArgsConstructor @AllArgsConstructor @Accessors(chain=true) @Slf4j -public class JAsset { +public class JAsset implements JsObjectView { public static final JAsset NULL_ASSET = new JAsset().setName("~null asset~").setPath("/dev/null"); public static final String PREFIX_CLASSPATH = "classpath:"; @@ -86,7 +87,8 @@ public class JAsset { return hasDest() && (dest.endsWith("/") || new File(dest).isDirectory()); } public File destDirectory() { - return mkdirOrDie(new File(dest.endsWith("/") ? dest.substring(0, dest.length()-1) : dest)); + final String dir = destIsDirectory() ? dest : dirname(dest); + return mkdirOrDie(new File(dir.endsWith("/") ? dir.substring(0, dir.length()-1) : dir)); } // if path was not a file, it got resolved to a file @@ -142,9 +144,21 @@ public class JAsset { } } - public BigDecimal duration() { return getInfo().duration(); } + public BigDecimal duration() { return hasInfo() ? getInfo().duration() : null; } @JsonIgnore public BigDecimal getDuration () { return duration(); } + public BigDecimal width() { return hasInfo() ? getInfo().width() : null; } + @JsonIgnore public BigDecimal getWidth () { return width(); } + + public BigDecimal height() { return hasInfo() ? getInfo().height() : null; } + @JsonIgnore public BigDecimal getHeight () { return height(); } + + public BigDecimal aspectRatio() { + final BigDecimal width = width(); + final BigDecimal height = height(); + return width == null || height == null ? null : width.divide(height, HALF_EVEN); + } + public JAsset init(AssetManager assetManager, Toolbox toolbox) { final JAsset asset = initPath(assetManager); if (!asset.hasListAssets()) { @@ -210,12 +224,16 @@ public class JAsset { }); final Pattern regex = Pattern.compile(b.toString()); final File dir = new File(path.substring(0, lastSlash)); - final String filesInDir = execScript("find " + dir + " -type f"); - final Set matches = Arrays.stream(filesInDir.split("\n")) - .filter(f -> regex.matcher(f).matches()) - .collect(Collectors.toCollection(() -> new TreeSet<>(comparing(String::toString)))); - for (String f : matches) { - addAsset(new JAsset(this).setPath(f)); + if (dir.exists()) { + final String filesInDir = execScript("find " + dir + " -type f"); + final Set matches = Arrays.stream(filesInDir.split("\n")) + .filter(f -> regex.matcher(f).matches()) + .collect(Collectors.toCollection(() -> new TreeSet<>(comparing(String::toString)))); + for (String f : matches) { + addAsset(new JAsset(this).setPath(f)); + } + } else { + return die("initPath: no files matched: "+path); } } else { @@ -227,4 +245,21 @@ public class JAsset { return this; } + @Override public Object toJs() { return new JAssetJs(this); } + + public static class JAssetJs { + public Integer duration; + public Integer width; + public Integer height; + public JAssetJs (JAsset asset) { + final BigDecimal d = asset.duration(); + this.duration = d == null ? null : d.intValue(); + + final BigDecimal w = asset.width(); + this.width = w == null ? null : w.intValue(); + + final BigDecimal h = asset.height(); + this.height = h == null ? null : h.intValue(); + } + } } diff --git a/src/main/java/jvcl/model/JOperationType.java b/src/main/java/jvcl/model/JOperationType.java index b04a754..939e916 100644 --- a/src/main/java/jvcl/model/JOperationType.java +++ b/src/main/java/jvcl/model/JOperationType.java @@ -2,6 +2,7 @@ package jvcl.model; import com.fasterxml.jackson.annotation.JsonCreator; import jvcl.op.ConcatOperation; +import jvcl.op.OverlayOperation; import jvcl.op.SplitOperation; import jvcl.op.TrimOperation; import jvcl.service.AssetManager; @@ -15,7 +16,7 @@ public enum JOperationType { concat (new ConcatOperation()), split (new SplitOperation()), trim (new TrimOperation()), - overlay (null), + overlay (new OverlayOperation()), ken_burns (null), letterbox (null), split_silence (null); diff --git a/src/main/java/jvcl/model/JsObjectView.java b/src/main/java/jvcl/model/JsObjectView.java new file mode 100644 index 0000000..aff0798 --- /dev/null +++ b/src/main/java/jvcl/model/JsObjectView.java @@ -0,0 +1,7 @@ +package jvcl.model; + +public interface JsObjectView { + + Object toJs(); + +} diff --git a/src/main/java/jvcl/model/info/JMediaInfo.java b/src/main/java/jvcl/model/info/JMediaInfo.java index 152ab12..59713c2 100644 --- a/src/main/java/jvcl/model/info/JMediaInfo.java +++ b/src/main/java/jvcl/model/info/JMediaInfo.java @@ -66,4 +66,26 @@ public class JMediaInfo { return longest; } + public BigDecimal width() { + if (media == null || empty(media.getTrack())) return BigDecimal.ZERO; + // find the first video track + for (JTrack t : media.getTrack()) { + if (!t.video()) continue; + if (!t.hasWidth()) continue; + return big(t.getWidth()); + } + return null; + } + + public BigDecimal height() { + if (media == null || empty(media.getTrack())) return BigDecimal.ZERO; + // find the first video track + for (JTrack t : media.getTrack()) { + if (!t.video()) continue; + if (!t.hasHeight()) continue; + return big(t.getHeight()); + } + return null; + } + } diff --git a/src/main/java/jvcl/model/info/JTrack.java b/src/main/java/jvcl/model/info/JTrack.java index 6e183eb..89d9ae2 100644 --- a/src/main/java/jvcl/model/info/JTrack.java +++ b/src/main/java/jvcl/model/info/JTrack.java @@ -42,9 +42,11 @@ public class JTrack { @JsonProperty("Width") @Getter @Setter private String width; public Integer width () { return parseInt(width); } + public boolean hasWidth () { return !empty(width); } @JsonProperty("Height") @Getter @Setter private String height; public Integer height () { return parseInt(height); } + public boolean hasHeight () { return !empty(height); } @JsonProperty("Sampled_Width") @Getter @Setter private String sampledWidth; @JsonProperty("Sampled_Height") @Getter @Setter private String sampledHeight; diff --git a/src/main/java/jvcl/op/OverlayOperation.java b/src/main/java/jvcl/op/OverlayOperation.java index fba1cf9..7ac4460 100644 --- a/src/main/java/jvcl/op/OverlayOperation.java +++ b/src/main/java/jvcl/op/OverlayOperation.java @@ -9,21 +9,32 @@ import jvcl.service.Toolbox; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.cobbzilla.util.javascript.JsEngine; +import java.io.File; import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; +import static java.math.RoundingMode.HALF_EVEN; import static jvcl.model.JAsset.json2asset; import static jvcl.service.Toolbox.getDuration; +import static org.cobbzilla.util.daemon.ZillaRuntime.big; import static org.cobbzilla.util.daemon.ZillaRuntime.empty; +import static org.cobbzilla.util.io.FileUtil.abs; +import static org.cobbzilla.util.io.FileUtil.basename; import static org.cobbzilla.util.system.CommandShell.execScript; @Slf4j public class OverlayOperation implements JOperator { public static final String OVERLAY_TEMPLATE - = "{{ffmpeg}} "; + = "{{ffmpeg}} -i {{{source.path}}} -filter_complex \"" + + "movie={{{overlay.path}}}{{#exists overlayStart}}:seek_point={{overlayStart}}{{/exists}} [ovl]; " + + "[v:0] setpts=PTS-(STARTPTS+{{#exists offset}}{{offset}}{{else}}0{{/exists}}) [main]; " + + "[ovl] setpts=PTS-STARTPTS{{#exists width}}, scale={{width}}x{{height}}{{/exists}} ; " + + "[main][ovl] overlay=shortest=1{{#exists x}}:x={{x}}{{/exists}}{{#exists y}}:y={{y}}{{/exists}} " + + "\" {{{output.path}}}"; @Override public void operate(JOperation op, Toolbox toolbox, AssetManager assetManager) { final OverlayConfig config = loadConfig(op, OverlayConfig.class); @@ -34,16 +45,44 @@ public class OverlayOperation implements JOperator { output.mergeFormat(source.getFormat()); final JFileExtension formatType = output.getFormat().getFileExtension(); + if (output.hasDest()) { + if (output.destExists() && !output.destIsDirectory()) { + log.info("operate: dest exists, not trimming: " + output.getDest()); + return; + } else if (output.destIsDirectory()) { + final File defaultFile = assetManager.assetPath(op, source, formatType, new Object[]{config}); + output.setPath(abs(new File(output.destDirectory(), basename(abs(defaultFile))))); + } else { + output.setPath(output.destPath()); + } + } final Map ctx = new HashMap<>(); + ctx.put("ffmpeg", toolbox.getFfmpeg()); ctx.put("source", source); ctx.put("overlay", overlay); ctx.put("output", output); - ctx.put("offset", config.getOffsetSeconds()); - ctx.put("overlayStart", config.getOverlayStartSeconds()); - if (config.hasOverlayEnd()) ctx.put("overlayEnd", config.getOverlayEndSeconds()); - if (config.hasWidth()) ctx.put("width", config.getWidth()); - if (config.hasHeight()) ctx.put("height", config.getHeight()); + ctx.put("offset", config.getOffsetSeconds(ctx, toolbox.getJs())); + ctx.put("overlayStart", config.getOverlayStartSeconds(ctx, toolbox.getJs())); + if (config.hasOverlayEnd()) ctx.put("overlayEnd", config.getOverlayEndSeconds(ctx, toolbox.getJs())); + if (config.hasWidth()) { + final String width = config.getWidth(ctx, toolbox.getJs()); + ctx.put("width", width); + if (!config.hasHeight()) { + final int height = big(width).divide(overlay.aspectRatio(), HALF_EVEN).intValue(); + ctx.put("height", height); + } + } + if (config.hasHeight()) { + final String height = config.getHeight(ctx, toolbox.getJs()); + ctx.put("height", height); + if (!config.hasWidth()) { + final int width = big(height).multiply(overlay.aspectRatio()).intValue(); + ctx.put("width", width); + } + } + if (config.hasX()) ctx.put("x", config.getX(ctx, toolbox.getJs())); + if (config.hasY()) ctx.put("y", config.getY(ctx, toolbox.getJs())); final String script = renderScript(toolbox, ctx, OVERLAY_TEMPLATE); @@ -57,32 +96,50 @@ public class OverlayOperation implements JOperator { @Getter @Setter private String source; @Getter @Setter private String overlay; + private String eval(String val, Map ctx, JsEngine js) { + final Map jsCtx = Toolbox.jsContext(ctx); + final Object result = js.evaluate(val, jsCtx); + return result == null ? null : result.toString(); + } + @Getter @Setter private String offset; - public BigDecimal getOffsetSeconds () { return empty(offset) ? BigDecimal.ZERO : getDuration(offset); } + public BigDecimal getOffsetSeconds (Map ctx, JsEngine js) { + return empty(offset) ? BigDecimal.ZERO : getDuration(eval(offset, ctx, js)); + } @Getter @Setter private String overlayStart; - public BigDecimal getOverlayStartSeconds () { return empty(overlayStart) ? BigDecimal.ZERO : getDuration(overlayStart); } + public BigDecimal getOverlayStartSeconds (Map ctx, JsEngine js) { + return empty(overlayStart) ? BigDecimal.ZERO : getDuration(eval(overlayStart, ctx, js)); + } @Getter @Setter private String overlayEnd; public boolean hasOverlayEnd () { return !empty(overlayEnd); } - public BigDecimal getOverlayEndSeconds () { return getDuration(overlayEnd); } + public BigDecimal getOverlayEndSeconds (Map ctx, JsEngine js) { + return getDuration(eval(overlayEnd, ctx, js)); + } @Getter @Setter private String width; public boolean hasWidth () { return !empty(width); } + public String getWidth(Map ctx, JsEngine js) { return eval(width, ctx, js); } @Getter @Setter private String height; public boolean hasHeight () { return !empty(height); } + public String getHeight(Map ctx, JsEngine js) { return eval(height, ctx, js); } @Getter @Setter private String x; public boolean hasX () { return !empty(x); } + public String getX(Map ctx, JsEngine js) { return eval(x, ctx, js); } @Getter @Setter private String y; public boolean hasY () { return !empty(y); } + public String getY(Map ctx, JsEngine js) { return eval(y, ctx, js); } @Getter @Setter private String outputWidth; public boolean hasOutputWidth () { return !empty(outputWidth); } + public String getOutputWidth(Map ctx, JsEngine js) { return eval(outputWidth, ctx, js); } @Getter @Setter private String outputHeight; public boolean hasOutputHeight () { return !empty(outputHeight); } + public String getOutputHeight(Map ctx, JsEngine js) { return eval(outputHeight, ctx, js); } } } diff --git a/src/main/java/jvcl/op/TrimOperation.java b/src/main/java/jvcl/op/TrimOperation.java index d7d0828..dbf47ae 100644 --- a/src/main/java/jvcl/op/TrimOperation.java +++ b/src/main/java/jvcl/op/TrimOperation.java @@ -51,34 +51,44 @@ public class TrimOperation implements JOperator { final File outfile; if (output.hasDest()) { outfile = new File(output.destDirectory(), basename(appendToFileNameBeforeExt(asset.getPath(), "_"+config.shortString()))); + if (outfile.exists()) { + log.info("operate: dest exists: "+abs(outfile)); + return; + } } else { outfile = defaultOutfile; } subOutput.setPath(abs(outfile)); - trim(config, asset, subOutput, toolbox, assetManager); + trim(config, asset, output, subOutput, toolbox, assetManager); } } else { - if (output.hasDest() && output.destExists()) { - log.info("operate: dest exists, not trimming: "+output.getDest()); + final File defaultOutfile = assetManager.assetPath(op, source, formatType, new Object[]{config}); + if (output.hasDest()) { + if (output.destExists() && !output.destIsDirectory()) { + log.info("operate: dest exists, not trimming: " + output.getDest()); + return; + } else if (output.destIsDirectory()) { + output.setPath(abs(new File(output.destDirectory(), basename(abs(defaultOutfile))))); + } else { + output.setPath(output.destPath()); + } } else { - trim(config, source, output, toolbox, assetManager); + output.setPath(abs(defaultOutfile)); } + trim(config, source, output, output, toolbox, assetManager); } } private void trim(TrimConfig config, JAsset source, JAsset output, + JAsset subOutput, Toolbox toolbox, AssetManager assetManager) { - if (output.destExists()) { - log.info("trim: dest exists: "+output.getDest()); - return; - } final Map ctx = new HashMap<>(); ctx.put("ffmpeg", toolbox.getFfmpeg()); ctx.put("source", source); - ctx.put("output", output); + ctx.put("output", subOutput); final BigDecimal startTime = config.getStartTime(); ctx.put("startSeconds", startTime); @@ -88,7 +98,11 @@ public class TrimOperation implements JOperator { log.debug("operate: running script: "+script); final String scriptOutput = execScript(script); log.debug("operate: command output: "+scriptOutput); - assetManager.addOperationAssetSlice(output, output); + if (output == subOutput) { + assetManager.addOperationAsset(output); + } else { + assetManager.addOperationAssetSlice(output, subOutput); + } } @NoArgsConstructor @EqualsAndHashCode diff --git a/src/main/java/jvcl/service/Toolbox.java b/src/main/java/jvcl/service/Toolbox.java index 4262688..989cdd4 100644 --- a/src/main/java/jvcl/service/Toolbox.java +++ b/src/main/java/jvcl/service/Toolbox.java @@ -2,6 +2,7 @@ package jvcl.service; import com.github.jknack.handlebars.Handlebars; import jvcl.model.JAsset; +import jvcl.model.JsObjectView; import jvcl.model.info.JMediaInfo; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -11,12 +12,12 @@ import org.cobbzilla.util.javascript.StandardJsEngine; import java.io.File; import java.math.BigDecimal; -import java.math.RoundingMode; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import static java.math.RoundingMode.HALF_EVEN; import static org.cobbzilla.util.daemon.ZillaRuntime.*; -import static org.cobbzilla.util.daemon.ZillaRuntime.big; import static org.cobbzilla.util.io.FileUtil.abs; import static org.cobbzilla.util.io.FileUtil.replaceExt; import static org.cobbzilla.util.json.JsonUtil.FULL_MAPPER_ALLOW_UNKNOWN_FIELDS; @@ -31,8 +32,23 @@ public class Toolbox { @Getter(lazy=true) private final Handlebars handlebars = initHandlebars(); + @Getter(lazy=true) private final StandardJsEngine js = new StandardJsEngine(); + public static BigDecimal getDuration(String t) { - return big(parseDuration(t)).divide(big(1000), RoundingMode.UNNECESSARY); + return big(parseDuration(t)).divide(big(1000), HALF_EVEN); + } + + public static Map jsContext(Map ctx) { + final Map jsCtx = new HashMap<>(); + for (Map.Entry entry : ctx.entrySet()) { + final Object value = entry.getValue(); + if (value instanceof JsObjectView) { + jsCtx.put(entry.getKey(), ((JsObjectView) value).toJs()); + } else { + jsCtx.put(entry.getKey(), value); + } + } + return jsCtx; } private Handlebars initHandlebars() { diff --git a/src/test/java/javicle/test/BasicTest.java b/src/test/java/javicle/test/BasicTest.java index ffd9904..e7bd91d 100644 --- a/src/test/java/javicle/test/BasicTest.java +++ b/src/test/java/javicle/test/BasicTest.java @@ -16,6 +16,7 @@ public class BasicTest { @Test public void testSplit () { runSpec("tests/test_split.json"); } @Test public void testConcat () { runSpec("tests/test_concat.json"); } @Test public void testTrim () { runSpec("tests/test_trim.json"); } + @Test public void testOverlay() { runSpec("tests/test_overlay.json"); } private void runSpec(String specPath) { @Cleanup("delete") final File specFile = stream2file(loadResourceAsStream(specPath)); diff --git a/src/test/resources/tests/test_overlay.json b/src/test/resources/tests/test_overlay.json index a51b950..2b7d0d1 100644 --- a/src/test/resources/tests/test_overlay.json +++ b/src/test/resources/tests/test_overlay.json @@ -35,5 +35,6 @@ "outputWidth": "1920", // output width in pixels. default is source width "outputHeight": "1024" // output height in pixes. default is source height } + } ] }