@@ -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 | |||
} | |||
] | |||
} | |||
@@ -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<OP>) execMap.computeIfAbsent(getClass(), c -> instantiate(getOperationExecClass(getClass()))); | |||
} | |||
public String shortString() { return safeShellArg(operation+"_"+sha256_hex(json(this))); } | |||
} |
@@ -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<String, Object> ctx, JsEngine js) { | |||
return "letterbox_"+color+"_"+getWidth(ctx, js)+"x"+getHeight(ctx, js); | |||
} | |||
} |
@@ -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<String, Object> 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() : ""); } | |||
} |
@@ -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<LetterboxOperation> { | |||
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<String, Object> 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<String, Object> 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); | |||
} | |||
} | |||
} |
@@ -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<ScaleOperation> { | |||
public class ScaleExec extends SingleOrMultiSourceExecBase<ScaleOperation> { | |||
public static final String SCALE_TEMPLATE | |||
= "{{ffmpeg}} -i {{{source.path}}} -filter_complex \"" | |||
@@ -45,42 +42,16 @@ public class ScaleExec extends ExecBase<ScaleOperation> { | |||
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<String, Object> ctx, | |||
JAsset source, | |||
JAsset output, | |||
JAsset subOutput, | |||
Toolbox toolbox, | |||
AssetManager assetManager) { | |||
@Override protected void process(Map<String, Object> ctx, | |||
ScaleOperation op, | |||
JAsset source, | |||
JAsset output, | |||
JAsset subOutput, | |||
Toolbox toolbox, | |||
AssetManager assetManager) { | |||
ctx.put("source", source); | |||
ctx.put("output", subOutput); | |||
@@ -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<OP extends JOperation> extends ExecBase<OP> { | |||
protected void operate(OP op, Toolbox toolbox, AssetManager assetManager, JAsset source, JAsset output, JFileExtension formatType, Map<String, Object> 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<String, Object> ctx, | |||
OP op, | |||
JAsset source, | |||
JAsset output, | |||
JAsset asset, | |||
Toolbox toolbox, | |||
AssetManager assetManager); | |||
} |
@@ -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<TrimOperation> { | |||
public class TrimExec extends SingleOrMultiSourceExecBase<TrimOperation> { | |||
public static final String TRIM_TEMPLATE | |||
= "{{ffmpeg}} -i {{{source.path}}} " + | |||
@@ -37,43 +34,16 @@ public class TrimExec extends ExecBase<TrimOperation> { | |||
final Map<String, Object> 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<String, Object> ctx, | |||
TrimOperation op, | |||
JAsset source, | |||
JAsset output, | |||
JAsset subOutput, | |||
Toolbox toolbox, | |||
AssetManager assetManager) { | |||
@Override protected void process(Map<String, Object> ctx, | |||
TrimOperation op, | |||
JAsset source, | |||
JAsset output, | |||
JAsset subOutput, | |||
Toolbox toolbox, | |||
AssetManager assetManager) { | |||
ctx.put("source", source); | |||
ctx.put("output", subOutput); | |||
@@ -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 { | |||
@@ -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"); } | |||
@@ -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" | |||
} | |||
] | |||
} |
@@ -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. | |||
} | |||
] | |||