@@ -127,8 +127,11 @@ The above would set the `start` value to ten seconds before the end of `someAsse | |||||
### Supported Operations | ### Supported Operations | ||||
Today, JVCL supports these operations: | Today, JVCL supports these operations: | ||||
### scale | |||||
Scale a video asset from one size to another | |||||
### split | ### split | ||||
Split an audio/video asset into multiple assets | |||||
Split an audio/video asset into multiple assets of equal time lengths | |||||
### concat | ### concat | ||||
Concatenate audio/video assets together into one asset | Concatenate audio/video assets together into one asset | ||||
@@ -136,9 +139,6 @@ Concatenate audio/video assets together into one asset | |||||
### trim | ### trim | ||||
Trim audio/video; crop a section of an asset, becomes a new asset | 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 | ||||
Overlay one asset onto another | Overlay one asset onto another | ||||
@@ -148,9 +148,6 @@ For transforming still images into video via a fade-pan (aka Ken Burns) effect | |||||
### letterbox | ### 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 | 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 | # Complex Example | ||||
Here is a complex example using multiple assets and operations. | 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": [ | "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 | "operation": "split", // name of the operation | ||||
"creates": "vid1_split_%", // assets it creates, the '%' will be replaced with a counter | "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 | "creates": "combined_vid", // the asset it creates, can be referenced later | ||||
"source": ["vid1", "vid2"] // operation-specific: this says, concatenate these named assets | "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 | "operation": "overlay", // name of the operation | ||||
"creates": "overlay1", // asset it creates | "creates": "overlay1", // asset it creates | ||||
@@ -186,13 +186,20 @@ public class JAsset implements JsObjectView { | |||||
final File sourcePath; | final File sourcePath; | ||||
if (hasDest()) { | if (hasDest()) { | ||||
if (destIsDirectory()) { | if (destIsDirectory()) { | ||||
sourcePath = new File(getDest(), basename(getName())); | |||||
sourcePath = new File(getDest(), basename(getPath())); | |||||
} else { | } else { | ||||
sourcePath = new File(getDest()); | sourcePath = new File(getDest()); | ||||
} | } | ||||
} else { | } else { | ||||
sourcePath = assetManager.sourcePath(getName()); | sourcePath = assetManager.sourcePath(getName()); | ||||
} | } | ||||
if (sourcePath.exists()) { | |||||
setOriginalPath(path); | |||||
setPath(abs(sourcePath)); | |||||
return this; | |||||
} | |||||
if (path.startsWith(PREFIX_CLASSPATH)) { | if (path.startsWith(PREFIX_CLASSPATH)) { | ||||
// it's a classpath resource | // it's a classpath resource | ||||
final String resource = path.substring(PREFIX_CLASSPATH.length()); | 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); | return evalBig(end, ctx, js); | ||||
} | } | ||||
public static class OverlayConfig { | |||||
public static class OverlayConfig implements HasWidthAndHeight { | |||||
@Getter @Setter private String source; | @Getter @Setter private String source; | ||||
@Getter @Setter private String start; | @Getter @Setter private String start; | ||||
@@ -43,12 +43,7 @@ public class OverlayOperation extends JSingleSourceOperation { | |||||
} | } | ||||
@Getter @Setter private String width; | @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; | @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; | @Getter @Setter private String x; | ||||
public boolean hasX () { return !empty(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.HashMap; | ||||
import java.util.Map; | import java.util.Map; | ||||
import static jvcl.service.Toolbox.divideBig; | |||||
import static org.cobbzilla.util.io.FileUtil.abs; | import static org.cobbzilla.util.io.FileUtil.abs; | ||||
import static org.cobbzilla.util.system.CommandShell.execScript; | import static org.cobbzilla.util.system.CommandShell.execScript; | ||||
@@ -55,24 +54,7 @@ public class OverlayExec extends ExecBase<OverlayOperation> { | |||||
ctx.put("overlayFilterConfig", overlayFilter); | ctx.put("overlayFilterConfig", overlayFilter); | ||||
ctx.put("output", output); | 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); | 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("ffmpeg", toolbox.getFfmpeg()); | ||||
ctx.put("source", source); | ctx.put("source", source); | ||||
assetManager.addOperationArrayAsset(output); | |||||
final BigDecimal incr = op.getIntervalIncr(ctx, js); | final BigDecimal incr = op.getIntervalIncr(ctx, js); | ||||
final BigDecimal endTime = op.getEndTime(source, ctx, js); | final BigDecimal endTime = op.getEndTime(source, ctx, js); | ||||
for (BigDecimal i = op.getStartTime(ctx, js); | for (BigDecimal i = op.getStartTime(ctx, js); | ||||
@@ -61,7 +62,7 @@ public class SplitExec extends ExecBase<SplitOperation> { | |||||
if (outfile.exists()) { | if (outfile.exists()) { | ||||
log.info("operate: outfile exists, not re-creating: "+abs(outfile)); | log.info("operate: outfile exists, not re-creating: "+abs(outfile)); | ||||
return; | |||||
continue; | |||||
} else { | } else { | ||||
mkdirOrDie(outfile.getParentFile()); | mkdirOrDie(outfile.getParentFile()); | ||||
} | } | ||||
@@ -34,6 +34,9 @@ public class TrimExec extends ExecBase<TrimOperation> { | |||||
final JAsset output = opCtx.output; | final JAsset output = opCtx.output; | ||||
final JFileExtension formatType = opCtx.formatType; | final JFileExtension formatType = opCtx.formatType; | ||||
final Map<String, Object> ctx = new HashMap<>(); | |||||
ctx.put("ffmpeg", toolbox.getFfmpeg()); | |||||
if (source.hasList()) { | if (source.hasList()) { | ||||
if (output.hasDest()) { | if (output.hasDest()) { | ||||
if (!output.destIsDirectory()) die("operate: dest is not a directory: "+output.getDest()); | if (!output.destIsDirectory()) die("operate: dest is not a directory: "+output.getDest()); | ||||
@@ -53,30 +56,29 @@ public class TrimExec extends ExecBase<TrimOperation> { | |||||
outfile = defaultOutfile; | outfile = defaultOutfile; | ||||
} | } | ||||
subOutput.setPath(abs(outfile)); | subOutput.setPath(abs(outfile)); | ||||
trim(op, asset, output, subOutput, toolbox, assetManager); | |||||
trim(ctx, op, asset, output, subOutput, toolbox, assetManager); | |||||
} | } | ||||
} else { | } else { | ||||
final File defaultOutfile = assetManager.assetPath(op, source, formatType); | final File defaultOutfile = assetManager.assetPath(op, source, formatType); | ||||
final File path = resolveOutputPath(output, defaultOutfile); | final File path = resolveOutputPath(output, defaultOutfile); | ||||
if (path == null) return; | if (path == null) return; | ||||
output.setPath(abs(path)); | 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 source, | ||||
JAsset output, | JAsset output, | ||||
JAsset subOutput, | JAsset subOutput, | ||||
Toolbox toolbox, | Toolbox toolbox, | ||||
AssetManager assetManager) { | 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("source", source); | ||||
ctx.put("output", subOutput); | ctx.put("output", subOutput); | ||||
final StandardJsEngine js = toolbox.getJs(); | |||||
final BigDecimal startTime = op.getStartTime(ctx, js); | final BigDecimal startTime = op.getStartTime(ctx, js); | ||||
ctx.put("startSeconds", startTime); | ctx.put("startSeconds", startTime); | ||||
if (op.hasEnd()) ctx.put("interval", op.getEndTime(ctx, js).subtract(startTime)); | if (op.hasEnd()) ctx.put("interval", op.getEndTime(ctx, js).subtract(startTime)); | ||||
@@ -18,10 +18,11 @@ import static org.junit.Assert.fail; | |||||
@Slf4j | @Slf4j | ||||
public class BasicTest { | public class BasicTest { | ||||
@Test public void testSplitConcatAndTrim () { | |||||
@Test public void testSplitConcatTrimScale () { | |||||
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"); | |||||
} | } | ||||
@Test public void testOverlay() { runSpec("tests/test_overlay.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. | |||||
} | |||||
] | |||||
} |