@@ -128,7 +128,7 @@ The above would set the `start` value to ten seconds before the end of `someAsse | |||||
Today, JVCL supports these operations: | Today, JVCL supports these operations: | ||||
### scale | ### 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 | ||||
Split an audio/video asset into multiple assets of equal time lengths | 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 | "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 | "width": "1024", // width of output video | ||||
"height": "768" // height 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.daemon.ZillaRuntime.hashOf; | ||||
import static org.cobbzilla.util.json.JsonUtil.json; | import static org.cobbzilla.util.json.JsonUtil.json; | ||||
import static org.cobbzilla.util.reflect.ReflectionUtil.instantiate; | 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 | @NoArgsConstructor @Accessors(chain=true) @Slf4j | ||||
@JsonTypeInfo( | @JsonTypeInfo( | ||||
@@ -41,4 +43,6 @@ public abstract class JOperation { | |||||
return (ExecBase<OP>) execMap.computeIfAbsent(getClass(), c -> instantiate(getOperationExecClass(getClass()))); | 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 java.math.BigDecimal.ZERO; | ||||
import static jvcl.service.Toolbox.evalBig; | import static jvcl.service.Toolbox.evalBig; | ||||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | ||||
import static org.cobbzilla.util.string.StringUtil.safeShellArg; | |||||
@Slf4j | @Slf4j | ||||
public class TrimOperation extends JSingleSourceOperation { | public class TrimOperation extends JSingleSourceOperation { | ||||
@@ -23,7 +24,7 @@ public class TrimOperation extends JSingleSourceOperation { | |||||
public boolean hasEnd() { return !empty(end); } | public boolean hasEnd() { return !empty(end); } | ||||
public BigDecimal getEndTime(Map<String, Object> ctx, JsEngine js) { return evalBig(end, ctx, js); } | 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() : ""); } | 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 lombok.extern.slf4j.Slf4j; | ||||
import org.cobbzilla.util.javascript.StandardJsEngine; | import org.cobbzilla.util.javascript.StandardJsEngine; | ||||
import java.io.File; | |||||
import java.math.BigDecimal; | import java.math.BigDecimal; | ||||
import java.util.HashMap; | import java.util.HashMap; | ||||
import java.util.Map; | 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; | import static org.cobbzilla.util.system.CommandShell.execScript; | ||||
@Slf4j | @Slf4j | ||||
public class ScaleExec extends ExecBase<ScaleOperation> { | |||||
public class ScaleExec extends SingleOrMultiSourceExecBase<ScaleOperation> { | |||||
public static final String SCALE_TEMPLATE | public static final String SCALE_TEMPLATE | ||||
= "{{ffmpeg}} -i {{{source.path}}} -filter_complex \"" | = "{{ffmpeg}} -i {{{source.path}}} -filter_complex \"" | ||||
@@ -45,42 +42,16 @@ public class ScaleExec extends ExecBase<ScaleOperation> { | |||||
op.setProportionalWidthAndHeight(ctx, js, source); | 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("source", source); | ||||
ctx.put("output", subOutput); | 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 lombok.extern.slf4j.Slf4j; | ||||
import org.cobbzilla.util.javascript.StandardJsEngine; | import org.cobbzilla.util.javascript.StandardJsEngine; | ||||
import java.io.File; | |||||
import java.math.BigDecimal; | import java.math.BigDecimal; | ||||
import java.util.HashMap; | import java.util.HashMap; | ||||
import java.util.Map; | 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; | import static org.cobbzilla.util.system.CommandShell.execScript; | ||||
@Slf4j | @Slf4j | ||||
public class TrimExec extends ExecBase<TrimOperation> { | |||||
public class TrimExec extends SingleOrMultiSourceExecBase<TrimOperation> { | |||||
public static final String TRIM_TEMPLATE | public static final String TRIM_TEMPLATE | ||||
= "{{ffmpeg}} -i {{{source.path}}} " + | = "{{ffmpeg}} -i {{{source.path}}} " + | ||||
@@ -37,43 +34,16 @@ public class TrimExec extends ExecBase<TrimOperation> { | |||||
final Map<String, Object> ctx = new HashMap<>(); | final Map<String, Object> ctx = new HashMap<>(); | ||||
ctx.put("ffmpeg", toolbox.getFfmpeg()); | 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("source", source); | ||||
ctx.put("output", subOutput); | 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.daemon.ZillaRuntime.*; | ||||
import static org.cobbzilla.util.io.FileUtil.*; | import static org.cobbzilla.util.io.FileUtil.*; | ||||
import static org.cobbzilla.util.json.JsonUtil.*; | import static org.cobbzilla.util.json.JsonUtil.*; | ||||
import static org.cobbzilla.util.string.StringUtil.safeShellArg; | |||||
import static org.cobbzilla.util.system.CommandShell.execScript; | import static org.cobbzilla.util.system.CommandShell.execScript; | ||||
@Slf4j | @Slf4j | ||||
@@ -84,10 +85,10 @@ public class Toolbox { | |||||
} | } | ||||
@Getter(lazy=true) private final String ffmpeg = initFfmpeg(); | @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(); | @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) { | private static String loadPath(String p) { | ||||
try { | try { | ||||
@@ -18,11 +18,12 @@ import static org.junit.Assert.fail; | |||||
@Slf4j | @Slf4j | ||||
public class BasicTest { | public class BasicTest { | ||||
@Test public void testSplitConcatTrimScale () { | |||||
@Test public void testSplitConcatTrimScaleLetterbox () { | |||||
runSpec("tests/test_split.jvcl"); | runSpec("tests/test_split.jvcl"); | ||||
runSpec("tests/test_concat.jvcl"); | runSpec("tests/test_concat.jvcl"); | ||||
runSpec("tests/test_trim.jvcl"); | runSpec("tests/test_trim.jvcl"); | ||||
runSpec("tests/test_scale.jvcl"); | runSpec("tests/test_scale.jvcl"); | ||||
runSpec("tests/test_letterbox.jvcl"); | |||||
} | } | ||||
@Test public void testOverlay() { runSpec("tests/test_overlay.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": [ | "operations": [ | ||||
{ | { | ||||
"operation": "scale", // name of the operation | |||||
"operation": "scale", // name of the operation | |||||
"creates": "scaled_test1", | "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 | "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 | "height": "768" // height of scaled asset. if omitted and width is present, height will be proportional | ||||
}, | }, | ||||
{ | { | ||||
"operation": "scale", // name of the operation | "operation": "scale", // name of the operation | ||||
"creates": "scaled_small", // asset it creates | "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. | "factor": "0.5" // scale factor. if factor is set, width and height are ignored. | ||||
} | } | ||||
] | ] | ||||