@@ -88,6 +88,9 @@ Today, JVCL supports several basic operations. | |||
For each operation listed below, the header links to an example from the JVCL | |||
test suite. | |||
### [add-silence](src/test/resources/tests/test_add_silence.jvcl) | |||
Add a silent audio track to a video asset. | |||
### [concat](src/test/resources/tests/test_concat.jvcl) | |||
Concatenate audio/video assets together into one asset. | |||
@@ -99,6 +102,9 @@ Transform a video from one size to another size, maintaining the aspect ratio | |||
of the video and adding letterboxes on the sides or top/bottom. | |||
Handy for embedding mobile videos into other screen formats. | |||
### [merge-audio](src/test/resources/tests/test_merge_audio.jvcl) | |||
Merge an audio asset into the audio track of a video asset. | |||
### [overlay](src/test/resources/tests/test_overlay.jvcl) | |||
Overlay one asset onto another. | |||
@@ -0,0 +1,35 @@ | |||
#!/bin/bash | |||
# | |||
# Add a silent audio track to a video asset | |||
# | |||
# Usage: | |||
# | |||
# jaddsilence in-file out-file | |||
# | |||
# in-file : input video file | |||
# out-file : write output file here | |||
# | |||
SCRIPT="${0}" | |||
SCRIPT_DIR="$(cd "$(dirname "${SCRIPT}")" && pwd)" | |||
. "${SCRIPT_DIR}"/jvcl_common | |||
IN_FILE="${1?no video-file provided}" | |||
OUT_FILE="${2?no out-file provided}" | |||
echo " | |||
{ | |||
\"assets\": [ | |||
{ \"name\": \"input\", \"path\": \"${IN_FILE}\" } | |||
], | |||
\"operations\": [ | |||
{ | |||
\"operation\": \"add-silence\", | |||
\"creates\": { | |||
\"name\": \"with_silence\", | |||
\"dest\": \"${OUT_FILE}\" | |||
}, | |||
\"source\": \"input\" | |||
} | |||
] | |||
} | |||
" | "${SCRIPT_DIR}"/jvcl ${JVCL_OPTIONS} |
@@ -0,0 +1,43 @@ | |||
#!/bin/bash | |||
# | |||
# Merge an audio asset into the audio track of a video asset | |||
# | |||
# Usage: | |||
# | |||
# jmergeaudio video-file audio-file out-file [at] | |||
# | |||
# video-file : input video file | |||
# audio-file : audio file to insert into video | |||
# out-file : write output file here | |||
# at : when (on the video timeline) to start playing the audio | |||
# If omitted, audio will start when video starts | |||
# | |||
SCRIPT="${0}" | |||
SCRIPT_DIR="$(cd "$(dirname "${SCRIPT}")" && pwd)" | |||
. "${SCRIPT_DIR}"/jvcl_common | |||
VIDEO_FILE="${1?no video-file provided}" | |||
AUDIO_FILE="${2?no audio-file provided}" | |||
OUT_FILE="${3?no out-file provided}" | |||
T_START="${4}" | |||
echo " | |||
{ | |||
\"assets\": [ | |||
{ \"name\": \"video_file\", \"path\": \"${VIDEO_FILE}\" }, | |||
{ \"name\": \"audio_file\", \"path\": \"${AUDIO_FILE}\" } | |||
], | |||
\"operations\": [ | |||
{ | |||
\"operation\": \"merge-audio\", | |||
\"creates\": { | |||
\"name\": \"with_audio\", | |||
\"dest\": \"${OUT_FILE}\" | |||
}, | |||
\"source\": \"video_file\", | |||
\"audio\": \"audio_file\"$(if [[ -n "${T_START}" ]] ; then echo ", | |||
\"at\": \"${T_START}\""; fi) | |||
} | |||
] | |||
} | |||
" | "${SCRIPT_DIR}"/jvcl ${JVCL_OPTIONS} |
@@ -38,6 +38,13 @@ field, which can be used as well. | |||
"name": "img1", | |||
"path": "https://live.staticflickr.com/65535/48159911972_01efa0e5ea_b.jpg", | |||
"dest": "src/test/resources/sources/" | |||
}, | |||
// Audio clip | |||
{ | |||
"name": "bull-roar", | |||
"path": "http://soundbible.com/grab.php?id=2073&type=mp3", | |||
"dest": "src/test/resources/sources/" | |||
} | |||
], | |||
"operations": [ | |||
@@ -172,6 +179,15 @@ field, which can be used as well. | |||
"type": "audio", // track type to remove | |||
"number": "0" // track number to remove | |||
} | |||
}, | |||
// merge-audio example | |||
{ | |||
"operation": "merge-audio", // name of the operation | |||
"creates": "with_roar", // output asset name | |||
"source": "vid2", // main video asset | |||
"insert": "bull-roar", // audio asset to insert | |||
"at": "5" // when (on the video timeline) to start playing the audio. default is 0 (beginning) | |||
} | |||
] | |||
} | |||
@@ -108,6 +108,10 @@ javicle is available under the Apache License, version 2: http://www.apache.org/ | |||
<groupId>com.codeborne</groupId> | |||
<artifactId>*</artifactId> | |||
</exclusion> | |||
<exclusion> | |||
<groupId>com.nixxcode.jvmbrotli</groupId> | |||
<artifactId>*</artifactId> | |||
</exclusion> | |||
<exclusion> | |||
<groupId>jtidy</groupId> | |||
<artifactId>*</artifactId> | |||
@@ -124,6 +128,14 @@ javicle is available under the Apache License, version 2: http://www.apache.org/ | |||
<groupId>org.apache.ant</groupId> | |||
<artifactId>*</artifactId> | |||
</exclusion> | |||
<exclusion> | |||
<groupId>org.bouncycastle</groupId> | |||
<artifactId>*</artifactId> | |||
</exclusion> | |||
<exclusion> | |||
<groupId>org.hamcrest</groupId> | |||
<artifactId>*</artifactId> | |||
</exclusion> | |||
<exclusion> | |||
<groupId>com.opencsv</groupId> | |||
<artifactId>*</artifactId> | |||
@@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; | |||
import org.apache.commons.io.IOUtils; | |||
import org.cobbzilla.util.collection.ArrayUtil; | |||
import org.cobbzilla.util.http.HttpUtil; | |||
import org.cobbzilla.util.http.URIUtil; | |||
import java.io.File; | |||
import java.io.FileOutputStream; | |||
@@ -168,6 +169,14 @@ public class JAsset implements JsObjectView { | |||
return width == null || height == null ? null : divideBig(width, height); | |||
} | |||
public BigDecimal samplingRate() { return hasInfo() ? getInfo().samplingRate() : null; } | |||
@JsonIgnore public BigDecimal getSamplingRate() { return samplingRate(); } | |||
public boolean hasSamplingRate() { return samplingRate() != null; } | |||
public String channelLayout() { return hasInfo() ? getInfo().channelLayout() : null; } | |||
@JsonIgnore public String getChannelLayout() { return channelLayout(); } | |||
public boolean hasChannelLayout() { return channelLayout() != null; } | |||
public JAsset init(AssetManager assetManager, Toolbox toolbox) { | |||
final JAsset asset = initPath(assetManager); | |||
if (!asset.hasListAssets()) { | |||
@@ -195,7 +204,7 @@ public class JAsset implements JsObjectView { | |||
final File sourcePath; | |||
if (hasDest()) { | |||
if (destIsDirectory()) { | |||
sourcePath = new File(getDest(), basename(getPath())); | |||
sourcePath = new File(getDest(), basename(URIUtil.getPath(getPath()))); | |||
} else { | |||
sourcePath = new File(getDest()); | |||
} | |||
@@ -11,16 +11,18 @@ import static jvcl.model.info.JTrackType.*; | |||
@AllArgsConstructor @Slf4j | |||
public enum JFileExtension { | |||
mp4 (".mp4", video), | |||
mkv (".mkv", video), | |||
mp3 (".mp3", audio), | |||
aac (".aac", audio), | |||
flac (".flac", audio), | |||
png (".png", image), | |||
jpg (".jpg", image), | |||
jpeg (".jpeg", image), | |||
sub (".sub", subtitle), | |||
dat (".dat", data); | |||
mp4 (".mp4", video), | |||
mkv (".mkv", video), | |||
mp3 (".mp3", audio), | |||
mpeg_audio (".mp3", audio), | |||
aac (".aac", audio), | |||
flac (".flac", audio), | |||
png (".png", image), | |||
jpg (".jpg", image), | |||
jpeg (".jpeg", image), | |||
sub (".sub", subtitle), | |||
dat (".dat", data), | |||
txt (".txt", data); | |||
@JsonCreator public static JFileExtension fromString(String v) { return valueOf(v.toLowerCase()); } | |||
@@ -45,7 +47,7 @@ public enum JFileExtension { | |||
} | |||
if (track.hasFormat()) { | |||
try { | |||
return fromString(track.getFormat()); | |||
return fromString(track.getFormat().replace(" ", "_")); | |||
} catch (Exception e) { | |||
log.warn("fromTrack: unrecognized format: "+track.getFormat()); | |||
} | |||
@@ -22,6 +22,7 @@ public class JMediaInfo { | |||
} | |||
@Getter @Setter private JMedia media; | |||
private boolean emptyMedia() { return media == null || empty(media.getTrack()); } | |||
private final AtomicReference<JFormat> formatRef = new AtomicReference<>(); | |||
@@ -31,7 +32,7 @@ public class JMediaInfo { | |||
} | |||
private JFormat initFormat () { | |||
if (media == null || empty(media.getTrack())) return null; | |||
if (emptyMedia()) return null; | |||
JTrack general = null; | |||
JTrack video = null; | |||
JTrack audio = null; | |||
@@ -64,12 +65,16 @@ public class JMediaInfo { | |||
final JFormat format = new JFormat(); | |||
if (video != null) { | |||
format.setFileExtension(JFileExtension.fromString(general.getFileExtension())) | |||
format.setFileExtension(video.hasFormat() | |||
? JFileExtension.fromTrack(video) | |||
: JFileExtension.fromString(general.getFileExtension())) | |||
.setHeight(video.height()) | |||
.setWidth(video.width()); | |||
} else if (audio != null) { | |||
format.setFileExtension(JFileExtension.fromString(general.getFileExtension())); | |||
format.setFileExtension(audio.hasFormat() | |||
? JFileExtension.fromTrack(audio) | |||
: JFileExtension.fromString(general.getFileExtension())); | |||
} else if (image != null) { | |||
format.setFileExtension(JFileExtension.fromString(general.getFileExtension())) | |||
@@ -83,7 +88,7 @@ public class JMediaInfo { | |||
} | |||
public BigDecimal duration() { | |||
if (media == null || empty(media.getTrack())) return ZERO; | |||
if (emptyMedia()) return ZERO; | |||
// find the longest media track | |||
BigDecimal longest = null; | |||
@@ -96,8 +101,27 @@ public class JMediaInfo { | |||
return longest; | |||
} | |||
public BigDecimal samplingRate() { | |||
if (emptyMedia()) return null; | |||
for (JTrack t : media.getTrack()) { | |||
if (!t.audioOrVideo()) continue; | |||
if (t.hasSamplingRate()) return big(t.getSamplingRate()); | |||
} | |||
return null; | |||
} | |||
public String channelLayout() { | |||
if (emptyMedia()) return null; | |||
for (JTrack t : media.getTrack()) { | |||
if (!t.audioOrVideo()) continue; | |||
final String channelLayout = t.channelLayout(); | |||
if (!empty(channelLayout)) return channelLayout; | |||
} | |||
return null; | |||
} | |||
public BigDecimal width() { | |||
if (media == null || empty(media.getTrack())) return ZERO; | |||
if (emptyMedia()) return ZERO; | |||
// find the first video track | |||
for (JTrack t : media.getTrack()) { | |||
if (!t.imageOrVideo()) continue; | |||
@@ -108,7 +132,7 @@ public class JMediaInfo { | |||
} | |||
public BigDecimal height() { | |||
if (media == null || empty(media.getTrack())) return ZERO; | |||
if (emptyMedia()) return ZERO; | |||
// find the first video track | |||
for (JTrack t : media.getTrack()) { | |||
if (!t.imageOrVideo()) continue; | |||
@@ -119,7 +143,7 @@ public class JMediaInfo { | |||
} | |||
public int numTracks(JTrackType type) { | |||
if (media == null || empty(media.getTrack())) return 0; | |||
if (emptyMedia()) return 0; | |||
int count = 0; | |||
for (JTrack t : media.getTrack()) { | |||
if (t.type() == type) count++; | |||
@@ -128,7 +152,7 @@ public class JMediaInfo { | |||
} | |||
public JTrack firstTrack(JTrackType type) { | |||
if (media == null || empty(media.getTrack())) return null; | |||
if (emptyMedia()) return null; | |||
for (JTrack t : media.getTrack()) { | |||
if (t.type() == type) return t; | |||
} | |||
@@ -7,6 +7,7 @@ import lombok.Setter; | |||
import static java.lang.Integer.parseInt; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | |||
import static org.cobbzilla.util.string.StringUtil.isOnlyDigits; | |||
public class JTrack { | |||
@@ -41,8 +42,26 @@ public class JTrack { | |||
@JsonProperty("Channels") @Getter @Setter private String channels; | |||
@JsonProperty("ChannelPositions") @Getter @Setter private String channelPositions; | |||
@JsonProperty("ChannelLayout") @Getter @Setter private String channelLayout; | |||
public String channelLayout () { | |||
if (!empty(channelLayout)) return channelLayout; | |||
if (!empty(channels)) { | |||
if (isOnlyDigits(channels)) { | |||
switch (parseInt(channels)) { | |||
case 1: return "mono"; | |||
case 2: return "stereo"; | |||
} | |||
} | |||
return channels; | |||
} | |||
return null; | |||
} | |||
@JsonProperty("SamplesPerFrame") @Getter @Setter private String samplesPerFrame; | |||
@JsonProperty("SamplingRate") @Getter @Setter private String samplingRate; | |||
public boolean hasSamplingRate () { return !empty(samplingRate); } | |||
@JsonProperty("SamplingCount") @Getter @Setter private String samplingCount; | |||
@JsonProperty("Compression_Mode") @Getter @Setter private String compressionMode; | |||
@JsonProperty("BitRate") @Getter @Setter private String bitrate; | |||
@@ -0,0 +1,5 @@ | |||
package jvcl.operation; | |||
import jvcl.model.operation.JSingleSourceOperation; | |||
public class AddSilenceOperation extends JSingleSourceOperation {} |
@@ -0,0 +1,32 @@ | |||
package jvcl.operation; | |||
import org.cobbzilla.util.javascript.JsEngine; | |||
import java.math.BigDecimal; | |||
import java.util.Map; | |||
import static java.math.BigDecimal.ZERO; | |||
import static jvcl.service.Toolbox.evalBig; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | |||
public interface HasStartAndEnd { | |||
String getStart (); | |||
default boolean hasStartTime () { return !empty(getStart()); } | |||
String getEnd (); | |||
default boolean hasEndTime () { return !empty(getEnd()); } | |||
default BigDecimal getStartTime(Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(getStart(), ctx, js, ZERO); | |||
} | |||
default BigDecimal getEndTime(Map<String, Object> ctx, JsEngine js) { | |||
return getEndTime(ctx, js, null); | |||
} | |||
default BigDecimal getEndTime(Map<String, Object> ctx, JsEngine js, BigDecimal defaultValue) { | |||
return evalBig(getEnd(), ctx, js, defaultValue); | |||
} | |||
} |
@@ -8,12 +8,12 @@ import org.cobbzilla.util.javascript.JsEngine; | |||
import java.math.BigDecimal; | |||
import java.util.Map; | |||
import static java.math.BigDecimal.ZERO; | |||
import static jvcl.service.Toolbox.evalBig; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.big; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | |||
public class KenBurnsOperation extends JSingleSourceOperation { | |||
public class KenBurnsOperation extends JSingleSourceOperation | |||
implements HasWidthAndHeight, HasStartAndEnd { | |||
public static final BigDecimal DEFAULT_FPS = big(25); | |||
public static final BigDecimal DEFAULT_UPSCALE = big(8); | |||
@@ -29,16 +29,7 @@ public class KenBurnsOperation extends JSingleSourceOperation { | |||
} | |||
@Getter @Setter private String start; | |||
public boolean hasStart () { return !empty(start); } | |||
public BigDecimal getStartTime(Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(start, ctx, js, ZERO); | |||
} | |||
@Getter @Setter private String end; | |||
public boolean hasEndTime () { return !empty(end); } | |||
public BigDecimal getEndTime(Map<String, Object> ctx, JsEngine js, BigDecimal defaultValue) { | |||
return evalBig(end, ctx, js, defaultValue); | |||
} | |||
@Getter @Setter private String x; | |||
public boolean hasX () { return !empty(x); } | |||
@@ -49,12 +40,7 @@ public class KenBurnsOperation extends JSingleSourceOperation { | |||
public BigDecimal getY(Map<String, Object> ctx, JsEngine js) { return evalBig(y, ctx, js); } | |||
@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 fps; | |||
public boolean hasFps () { return !empty(fps); } | |||
@@ -0,0 +1,21 @@ | |||
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 java.math.BigDecimal.ZERO; | |||
import static jvcl.service.Toolbox.evalBig; | |||
public class MergeAudioOperation extends JSingleSourceOperation { | |||
@Getter @Setter private String insert; | |||
@Getter @Setter private String at; | |||
public BigDecimal getAt(Map<String, Object> ctx, JsEngine js) { return evalBig(at, ctx, js, ZERO); } | |||
} |
@@ -9,38 +9,23 @@ import org.cobbzilla.util.javascript.JsEngine; | |||
import java.math.BigDecimal; | |||
import java.util.Map; | |||
import static java.math.BigDecimal.ZERO; | |||
import static jvcl.service.Toolbox.evalBig; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | |||
@Slf4j | |||
public class OverlayOperation extends JSingleSourceOperation { | |||
public class OverlayOperation extends JSingleSourceOperation implements HasStartAndEnd { | |||
@Getter @Setter private OverlayConfig overlay; | |||
@Getter @Setter private String start; | |||
public BigDecimal getStartTime(Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(start, ctx, js, ZERO); | |||
} | |||
@Getter @Setter private String end; | |||
public BigDecimal getEndTime(Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(end, ctx, js); | |||
} | |||
public static class OverlayConfig implements HasWidthAndHeight { | |||
public static class OverlayConfig implements HasStartAndEnd, HasWidthAndHeight { | |||
@Getter @Setter private String source; | |||
@Getter @Setter private String start; | |||
public BigDecimal getStartTime(Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(start, ctx, js, ZERO); | |||
} | |||
@Getter @Setter private String end; | |||
public boolean hasEndTime () { return !empty(end); } | |||
public BigDecimal getEndTime(Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(end, ctx, js); | |||
} | |||
@Getter @Setter private String width; | |||
@Getter @Setter private String height; | |||
@@ -52,6 +37,6 @@ public class OverlayOperation extends JSingleSourceOperation { | |||
@Getter @Setter private String y; | |||
public boolean hasY () { return !empty(y); } | |||
public BigDecimal getY(Map<String, Object> ctx, JsEngine js) { return evalBig(y, ctx, js); } | |||
} | |||
} |
@@ -1,6 +1,5 @@ | |||
package jvcl.operation; | |||
import jvcl.model.JAsset; | |||
import jvcl.model.operation.JSingleSourceOperation; | |||
import lombok.Getter; | |||
import lombok.Setter; | |||
@@ -10,11 +9,10 @@ import org.cobbzilla.util.javascript.JsEngine; | |||
import java.math.BigDecimal; | |||
import java.util.Map; | |||
import static java.math.BigDecimal.ZERO; | |||
import static jvcl.service.Toolbox.evalBig; | |||
@Slf4j | |||
public class SplitOperation extends JSingleSourceOperation { | |||
public class SplitOperation extends JSingleSourceOperation implements HasStartAndEnd { | |||
@Getter @Setter private String interval; | |||
public BigDecimal getIntervalIncr(Map<String, Object> ctx, JsEngine js) { | |||
@@ -22,13 +20,6 @@ public class SplitOperation extends JSingleSourceOperation { | |||
} | |||
@Getter @Setter private String start; | |||
public BigDecimal getStartTime(Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(start, ctx, js, ZERO); | |||
} | |||
@Getter @Setter private String end; | |||
public BigDecimal getEndTime(JAsset asset, Map<String, Object> ctx, JsEngine js) { | |||
return evalBig(end, ctx, js, asset.duration()); | |||
} | |||
} |
@@ -4,27 +4,16 @@ import jvcl.model.operation.JSingleSourceOperation; | |||
import lombok.Getter; | |||
import lombok.Setter; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.util.javascript.JsEngine; | |||
import java.math.BigDecimal; | |||
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 { | |||
public class TrimOperation extends JSingleSourceOperation implements HasStartAndEnd { | |||
@Getter @Setter private String start; | |||
public BigDecimal getStartTime(Map<String, Object> ctx, JsEngine js) { return evalBig(start, ctx, js, ZERO); } | |||
@Getter @Setter private String end; | |||
public boolean hasEnd() { return !empty(end); } | |||
public BigDecimal getEndTime(Map<String, Object> ctx, JsEngine js) { return evalBig(end, ctx, js); } | |||
@Override public String shortString() { return safeShellArg("trim_" + getStart() + (hasEnd() ? "_" + getEnd() : "")); } | |||
public String toString() { return getSource()+"_"+getStart()+(hasEnd() ? "_"+getEnd() : ""); } | |||
@Override public String shortString() { return safeShellArg("trim_" + getStart() + (hasEndTime() ? "_" + getEnd() : "")); } | |||
public String toString() { return getSource()+"_"+getStart()+(hasEndTime() ? "_"+getEnd() : ""); } | |||
} |
@@ -0,0 +1,36 @@ | |||
package jvcl.operation.exec; | |||
import jvcl.model.JAsset; | |||
import jvcl.model.JFileExtension; | |||
import jvcl.model.operation.JSingleOperationContext; | |||
import jvcl.operation.AddSilenceOperation; | |||
import jvcl.service.AssetManager; | |||
import jvcl.service.Toolbox; | |||
import lombok.extern.slf4j.Slf4j; | |||
import java.util.HashMap; | |||
import java.util.Map; | |||
@Slf4j | |||
public class AddSilenceExec extends SingleOrMultiSourceExecBase<AddSilenceOperation> { | |||
public static final String ADD_SILENCE_TEMPLATE = ""; | |||
@Override public void operate(AddSilenceOperation 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 Map<String, Object> ctx = new HashMap<>(); | |||
ctx.put("ffmpeg", toolbox.getFfmpeg()); | |||
ctx.put("source", source); | |||
operate(op, toolbox, assetManager, source, output, formatType, ctx); | |||
} | |||
@Override protected void process(Map<String, Object> ctx, AddSilenceOperation addSilenceOperation, JAsset source, JAsset output, JAsset asset, Toolbox toolbox, AssetManager assetManager) { | |||
// todo | |||
} | |||
} |
@@ -67,7 +67,7 @@ public class KenBurnsExec extends ExecBase<KenBurnsOperation> { | |||
final BigDecimal start = op.getStartTime(ctx, js); | |||
final BigDecimal end = op.getEndTime(ctx, js, duration.subtract(start)); | |||
if (op.hasStart() || op.hasEndTime()) { | |||
if (op.hasStartTime() || op.hasEndTime()) { | |||
ctx.put("startFrame", start.multiply(fps).intValue()); | |||
ctx.put("endFrame", end.multiply(fps).intValue()); | |||
} | |||
@@ -0,0 +1,161 @@ | |||
package jvcl.operation.exec; | |||
import jvcl.model.JAsset; | |||
import jvcl.model.JFileExtension; | |||
import jvcl.model.operation.JSingleOperationContext; | |||
import jvcl.operation.MergeAudioOperation; | |||
import jvcl.service.AssetManager; | |||
import jvcl.service.Toolbox; | |||
import lombok.Cleanup; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.util.io.TempDir; | |||
import org.cobbzilla.util.javascript.StandardJsEngine; | |||
import java.io.File; | |||
import java.math.BigDecimal; | |||
import java.util.HashMap; | |||
import java.util.Map; | |||
import static java.math.BigDecimal.ZERO; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.die; | |||
import static org.cobbzilla.util.io.FileUtil.*; | |||
import static org.cobbzilla.util.system.OsType.CURRENT_OS; | |||
import static org.cobbzilla.util.system.OsType.windows; | |||
@Slf4j | |||
public class MergeAudioExec extends SingleOrMultiSourceExecBase<MergeAudioOperation> { | |||
public static final String CREATE_SILENCE_TEMPLATE | |||
= "{{{ffmpeg}}} -f lavfi " | |||
+ "-i anullsrc=channel_layout={{channelLayout}}:sample_rate={{samplingRate}} " | |||
+ "-t {{duration}} " | |||
+ "-y {{{silence.path}}}"; | |||
public static final String PAD_WITH_SILENCE_TEMPLATE | |||
= "cd {{{tempDir}}} && {{{ffmpeg}}} -f concat -i {{{playlist.path}}} -codec copy -y {{{padded}}}"; | |||
public static final String MERGE_AUDIO_TEMPLATE | |||
= "{{{ffmpeg}}} -i {{{source.path}}} -i {{audio.path}} -filter_complex \"" | |||
+ "[0:a][1:a] amix=inputs=2 [merged]" | |||
+ "\" " | |||
+ "-map 0:v -map \"[merged]\" -c:v copy " | |||
+ "-y {{{output.path}}}"; | |||
@Override public void operate(MergeAudioOperation 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 JAsset audio = assetManager.resolve(op.getInsert()); | |||
final StandardJsEngine js = toolbox.getJs(); | |||
final Map<String, Object> ctx = new HashMap<>(); | |||
ctx.put("ffmpeg", toolbox.getFfmpeg()); | |||
ctx.put("source", source); | |||
ctx.put("audio", audio); | |||
final BigDecimal insertAt = op.getAt(ctx, js); | |||
ctx.put("start", insertAt); | |||
if (insertAt.compareTo(ZERO) > 0) { | |||
final JAsset silence = createSilence(op, toolbox, assetManager, insertAt, audio); | |||
final JAsset padded = padWithSilence(op, toolbox, assetManager, audio, silence); | |||
ctx.put("audio", padded); | |||
} | |||
operate(op, toolbox, assetManager, source, output, formatType, ctx); | |||
} | |||
protected JAsset createSilence(MergeAudioOperation op, | |||
Toolbox toolbox, | |||
AssetManager assetManager, | |||
BigDecimal duration, | |||
JAsset audio) { | |||
final Map<String, Object> ctx = new HashMap<>(); | |||
ctx.put("ffmpeg", toolbox.getFfmpeg()); | |||
ctx.put("duration", duration); | |||
if (!audio.hasSamplingRate()) return die("createSilence: no sampling rate could be determined: "+audio); | |||
ctx.put("samplingRate", audio.samplingRate()); | |||
if (!audio.hasChannelLayout()) return die("createSilence: no channel layout could be determined: "+audio); | |||
ctx.put("channelLayout", audio.channelLayout()); | |||
final JFileExtension ext = audio.getFormat().getFileExtension(); | |||
final File silenceFile = assetManager.assetPath(op, audio, ext, new Object[]{duration}); | |||
final JAsset silence = new JAsset().setPath(abs(silenceFile)); | |||
ctx.put("silence", silence); | |||
final String script = renderScript(toolbox, ctx, CREATE_SILENCE_TEMPLATE); | |||
log.debug("operate: running script: "+script); | |||
final String scriptOutput = exec(script, op.isNoExec()); | |||
log.debug("operate: command output: "+scriptOutput); | |||
return silence; | |||
} | |||
protected JAsset padWithSilence(MergeAudioOperation op, | |||
Toolbox toolbox, | |||
AssetManager assetManager, | |||
JAsset audio, | |||
JAsset silence) { | |||
final Map<String, Object> ctx = new HashMap<>(); | |||
ctx.put("ffmpeg", toolbox.getFfmpeg()); | |||
final JFileExtension ext = audio.getFormat().getFileExtension(); | |||
final JAsset padded = new JAsset().setPath(abs(assetManager.assetPath(op, audio, ext))); | |||
final String paddedName = basename(padded.getPath()); | |||
ctx.put("padded", paddedName); | |||
// create a temp dir for concat, it really likes to have everything in the same directory | |||
final boolean doChmod = CURRENT_OS != windows; // don't chmod the dir on windows | |||
@Cleanup("delete") final TempDir tempDir = new TempDir(assetManager.getScratchDir(), doChmod); | |||
ctx.put("tempDir", abs(tempDir)); | |||
final String silenceName = basename(silence.getPath()); | |||
final String audioName = basename(audio.getPath()); | |||
// write playlist | |||
final File playlistFile = new File(tempDir, "playlist.txt"); | |||
toFileOrDie(playlistFile, "file "+ silenceName +"\nfile "+ audioName); | |||
// copy audio and silence assets to temp dir | |||
copyFile(new File(silence.getPath()), new File(tempDir, silenceName)); | |||
copyFile(new File(audio.getPath()), new File(tempDir, audioName)); | |||
final JAsset playlist = new JAsset().setPath(abs(playlistFile)); | |||
ctx.put("playlist", playlist); | |||
final String script = renderScript(toolbox, ctx, PAD_WITH_SILENCE_TEMPLATE); | |||
log.debug("padWithSilence: running script: "+script); | |||
final String scriptOutput = exec(script, op.isNoExec()); | |||
final File outputFile = new File(tempDir, paddedName); | |||
if (!outputFile.exists()) return die("padWithSilence: output file not found: "+abs(outputFile)); | |||
copyFile(outputFile, new File(abs(padded.getPath()))); | |||
log.debug("padWithSilence: command output: "+scriptOutput); | |||
return padded; | |||
} | |||
@Override protected void process(Map<String, Object> ctx, | |||
MergeAudioOperation 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, MERGE_AUDIO_TEMPLATE); | |||
log.debug("operate: running script: "+script); | |||
final String scriptOutput = exec(script, op.isNoExec()); | |||
log.debug("operate: command output: "+scriptOutput); | |||
} | |||
} |
@@ -38,7 +38,7 @@ public class SplitExec extends ExecBase<SplitOperation> { | |||
assetManager.addOperationArrayAsset(output); | |||
final BigDecimal incr = op.getIntervalIncr(ctx, js); | |||
final BigDecimal endTime = op.getEndTime(source, ctx, js); | |||
final BigDecimal endTime = op.getEndTime(ctx, js, source.duration()); | |||
for (BigDecimal i = op.getStartTime(ctx, js); | |||
i.compareTo(endTime) < 0; | |||
i = i.add(incr)) { | |||
@@ -49,7 +49,7 @@ public class TrimExec extends SingleOrMultiSourceExecBase<TrimOperation> { | |||
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)); | |||
if (op.hasEndTime()) ctx.put("interval", op.getEndTime(ctx, js).subtract(startTime)); | |||
final String script = renderScript(toolbox, ctx, TRIM_TEMPLATE); | |||
log.debug("operate: running script: "+script); | |||
@@ -3,6 +3,7 @@ package jvcl.service; | |||
import jvcl.model.JAsset; | |||
import jvcl.model.JFileExtension; | |||
import jvcl.model.operation.JOperation; | |||
import lombok.Getter; | |||
import org.cobbzilla.util.handlebars.HandlebarsUtil; | |||
import java.io.File; | |||
@@ -23,7 +24,7 @@ public class AssetManager { | |||
public static final String RANGE_SEP = ".."; | |||
private final Toolbox toolbox; | |||
private final File scratchDir; | |||
@Getter private final File scratchDir; | |||
private final Map<String, JAsset> assets = new ConcurrentHashMap<>(); | |||
public AssetManager(Toolbox toolbox, File scratchDir) { | |||
@@ -108,7 +108,8 @@ public class Toolbox { | |||
final File infoFile = new File(infoName); | |||
final String infoPath = abs(infoFile); | |||
if (!infoFile.exists() || infoFile.length() == 0) { | |||
execScript(getMediainfo() + " --Output=JSON " + abs(asset.getPath())+" > "+infoPath); | |||
final String mediaInfoScript = getMediainfo() + " --Output=JSON " + abs(asset.getPath()) + " > " + infoPath; | |||
execScript(mediaInfoScript); | |||
} | |||
if (!infoFile.exists() || infoFile.length() == 0) { | |||
return die("getInfo: info file was not created or was empty: "+infoPath); | |||
@@ -29,6 +29,8 @@ public class BasicTest { | |||
@Test public void testOverlay () { runSpec("tests/test_overlay.jvcl"); } | |||
@Test public void testKenBurns () { runSpec("tests/test_ken_burns.jvcl"); } | |||
@Test public void testRemoveTrack () { runSpec("tests/test_remove_track.jvcl"); } | |||
@Test public void testMergeAudio () { runSpec("tests/test_merge_audio.jvcl"); } | |||
@Test public void testAddSilence () { runSpec("tests/test_add_silence.jvcl"); } | |||
private void runSpec(String specPath) { | |||
try { | |||
@@ -0,0 +1,25 @@ | |||
{ | |||
"assets": [ | |||
// this US government videos is covered by copyright | |||
{ | |||
"name": "vid2", | |||
"path": "https://archive.org/download/gov.archives.arc.49442/gov.archives.arc.49442_512kb.mp4", | |||
"dest": "src/test/resources/sources/" | |||
} | |||
], | |||
"operations": [ | |||
// trim video first, so test runs faster | |||
{ | |||
"operation": "trim", | |||
"creates": "v2", | |||
"source": "vid2", | |||
"start": "10", | |||
"end": "30" | |||
}, | |||
{ | |||
"operation": "add-silence", // name of the operation | |||
"creates": "v2_silent", // output asset name | |||
"source": "v2" // main video asset | |||
} | |||
] | |||
} |
@@ -0,0 +1,33 @@ | |||
{ | |||
"assets": [ | |||
// this US government videos is covered by copyright | |||
{ | |||
"name": "vid2", | |||
"path": "https://archive.org/download/gov.archives.arc.49442/gov.archives.arc.49442_512kb.mp4", | |||
"dest": "src/test/resources/sources/" | |||
}, | |||
// this sound clip is in the public domain: http://soundbible.com/2073-Red-Stag-Roar.html | |||
{ | |||
"name": "bull-roar", | |||
"path": "http://soundbible.com/grab.php?id=2073&type=mp3", | |||
"dest": "src/test/resources/sources/" | |||
} | |||
], | |||
"operations": [ | |||
// trim video first, so test runs faster | |||
{ | |||
"operation": "trim", | |||
"creates": "v2", | |||
"source": "vid2", | |||
"start": "10", | |||
"end": "30" | |||
}, | |||
{ | |||
"operation": "merge-audio", // name of the operation | |||
"creates": "with_roar", // output asset name | |||
"source": "v2", // main video asset | |||
"insert": "bull-roar", // audio asset to insert | |||
"at": "5" // when (on the video timeline) to start playing the audio. default is 0 (beginning) | |||
} | |||
] | |||
} |
@@ -1 +1 @@ | |||
Subproject commit 81913bcc9d42c625451043dfa3b0fd482a2ea832 | |||
Subproject commit d9e13332dbba7d3d95a582f50609e62becaab9cc |