@@ -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<String> 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<String> 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(); | |||
} | |||
} | |||
} |
@@ -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); | |||
@@ -0,0 +1,7 @@ | |||
package jvcl.model; | |||
public interface JsObjectView { | |||
Object toJs(); | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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; | |||
@@ -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<String, Object> 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<String, Object> ctx, JsEngine js) { | |||
final Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> ctx, JsEngine js) { | |||
return getDuration(eval(overlayEnd, ctx, js)); | |||
} | |||
@Getter @Setter private String width; | |||
public boolean hasWidth () { return !empty(width); } | |||
public String getWidth(Map<String, Object> ctx, JsEngine js) { return eval(width, ctx, js); } | |||
@Getter @Setter private String height; | |||
public boolean hasHeight () { return !empty(height); } | |||
public String getHeight(Map<String, Object> ctx, JsEngine js) { return eval(height, ctx, js); } | |||
@Getter @Setter private String x; | |||
public boolean hasX () { return !empty(x); } | |||
public String getX(Map<String, Object> ctx, JsEngine js) { return eval(x, ctx, js); } | |||
@Getter @Setter private String y; | |||
public boolean hasY () { return !empty(y); } | |||
public String getY(Map<String, Object> ctx, JsEngine js) { return eval(y, ctx, js); } | |||
@Getter @Setter private String outputWidth; | |||
public boolean hasOutputWidth () { return !empty(outputWidth); } | |||
public String getOutputWidth(Map<String, Object> ctx, JsEngine js) { return eval(outputWidth, ctx, js); } | |||
@Getter @Setter private String outputHeight; | |||
public boolean hasOutputHeight () { return !empty(outputHeight); } | |||
public String getOutputHeight(Map<String, Object> ctx, JsEngine js) { return eval(outputHeight, ctx, js); } | |||
} | |||
} |
@@ -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<String, Object> 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 | |||
@@ -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<String, Object> jsContext(Map<String, Object> ctx) { | |||
final Map<String, Object> jsCtx = new HashMap<>(); | |||
for (Map.Entry<String, Object> 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() { | |||
@@ -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)); | |||
@@ -35,5 +35,6 @@ | |||
"outputWidth": "1920", // output width in pixels. default is source width | |||
"outputHeight": "1024" // output height in pixes. default is source height | |||
} | |||
} | |||
] | |||
} |