@@ -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 | |||
@@ -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()); | |||
@@ -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<String, Object> ctx, JsEngine js) { return evalBig(getWidth(), ctx, js); } | |||
default BigDecimal getHeight(Map<String, Object> ctx, JsEngine js) { return evalBig(getHeight(), ctx, js); } | |||
default void setProportionalWidthAndHeight(Map<String, Object> 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); | |||
} | |||
} | |||
} | |||
} |
@@ -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<String, Object> ctx, JsEngine js) { return evalBig(width, ctx, js); } | |||
@Getter @Setter private String height; | |||
public boolean hasHeight () { return !empty(height); } | |||
public BigDecimal getHeight(Map<String, Object> ctx, JsEngine js) { return evalBig(height, ctx, js); } | |||
@Getter @Setter private String x; | |||
public boolean hasX () { return !empty(x); } | |||
@@ -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<String, Object> ctx, JsEngine js) { return evalBig(factor, ctx, js); } | |||
@Getter @Setter private String width; | |||
@Getter @Setter private String height; | |||
public String shortString(Map<String, Object> ctx, JsEngine js) { | |||
return "scaled_"+(hasFactor() ? getFactor(ctx, js)+"x" : getWidth(ctx, js)+"x"+getHeight(ctx, js)); | |||
} | |||
} |
@@ -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<OverlayOperation> { | |||
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); | |||
@@ -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<ScaleOperation> { | |||
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<String, Object> 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<String, Object> 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); | |||
} | |||
} | |||
} |
@@ -37,6 +37,7 @@ public class SplitExec extends ExecBase<SplitOperation> { | |||
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<SplitOperation> { | |||
if (outfile.exists()) { | |||
log.info("operate: outfile exists, not re-creating: "+abs(outfile)); | |||
return; | |||
continue; | |||
} else { | |||
mkdirOrDie(outfile.getParentFile()); | |||
} | |||
@@ -34,6 +34,9 @@ public class TrimExec extends ExecBase<TrimOperation> { | |||
final JAsset output = opCtx.output; | |||
final JFileExtension formatType = opCtx.formatType; | |||
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()); | |||
@@ -53,30 +56,29 @@ public class TrimExec extends ExecBase<TrimOperation> { | |||
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<String, Object> ctx, | |||
TrimOperation op, | |||
JAsset source, | |||
JAsset output, | |||
JAsset subOutput, | |||
Toolbox toolbox, | |||
AssetManager assetManager) { | |||
final StandardJsEngine js = toolbox.getJs(); | |||
final Map<String, Object> 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)); | |||
@@ -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"); } | |||
@@ -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. | |||
} | |||
] | |||
} |