@@ -47,4 +47,54 @@ What variables are available in the JS context? And what properties do they have | |||
Assets can be defined in the JVC's `assets` array, or as the output of an | |||
operation. | |||
Assets are referenced by their asset name. | |||
Assets are referenced by their asset name. Assets have some useful properties: | |||
#### `duration` | |||
Duration in seconds of the asset (audio or video). | |||
#### `width` | |||
Resolution width in pixes (video or image). | |||
#### `height` | |||
Resolution height in pixes (video or image). | |||
#### `tracks` | |||
An array of the tracks in a video. Only includes audio and video tracks. | |||
#### `audioTracks` | |||
An array of the audio tracks in a video. | |||
#### `videoTracks` | |||
An array of the video tracks in a video. | |||
#### `assets` | |||
If an asset is a list asset, this is an array of the sub-assets. Each sub-asset | |||
has the same properties described above. | |||
#### more properties | |||
The list of asset properties supported today is fairly limited. | |||
More properties will be exposed in the future. | |||
### JS Functions | |||
The JavaScript environment that JVC sets up includes some useful built-in | |||
functions. | |||
#### `is_within` | |||
Check if a variable is "close" to another value by comparing against a delta. | |||
```js | |||
x = 10.2 | |||
is_within(x, 10, 1); // true | |||
is_within(x, 9.5, 0.5); // false | |||
is_within(x, 10.3, 0.1); // true | |||
``` | |||
#### `is_within_pct` | |||
Check if a variable is "close" to another value by percentage. The percentage | |||
argument is an integer where 1 == 1% and 100 == 100% | |||
```js | |||
x = 10.2 | |||
is_within_pct(x, 10, 2); // true | |||
is_within_pct(x, 9, 10); // false (9 + (10% of 9) only gets you to 9.9, x is 10.2) | |||
is_within_pct(x, 10.3, 10); // true | |||
``` | |||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; | |||
import jvc.model.info.JMediaInfo; | |||
import jvc.model.info.JTrack; | |||
import jvc.model.info.JTrackType; | |||
import jvc.model.js.JAssetJs; | |||
import jvc.service.AssetManager; | |||
import jvc.service.Toolbox; | |||
import lombok.*; | |||
@@ -44,8 +45,11 @@ public class JAsset implements JsObjectView { | |||
public JAsset(JAsset other) { copy(this, other, null, COPY_EXCLUDE_FIELDS); } | |||
public JAsset(JAsset other, File file) { this(other); setPath(abs(file)); } | |||
@Getter @Setter private String name; | |||
@Getter @Setter private String path; | |||
public boolean hasPath() { return !empty(path); } | |||
// an asset can specify where its file should live | |||
@@ -114,7 +118,7 @@ public class JAsset implements JsObjectView { | |||
setFormat(info.getFormat()); | |||
return this; | |||
} | |||
public boolean hasInfo() { return info != null; } | |||
public boolean hasInfo() { return info != null && !info.emptyMedia(); } | |||
@Getter @Setter private String comment; | |||
public boolean hasComment () { return !empty(comment); } | |||
@@ -285,19 +289,4 @@ public class JAsset implements JsObjectView { | |||
@Override public Object toJs() { return new JAssetJs(this); } | |||
public static class JAssetJs { | |||
public Double duration; | |||
public Integer width; | |||
public Integer height; | |||
public JAssetJs (JAsset asset) { | |||
final BigDecimal d = asset.duration(); | |||
this.duration = d == null ? null : d.doubleValue(); | |||
final BigDecimal w = asset.width(); | |||
this.width = w == null ? null : w.intValue(); | |||
final BigDecimal h = asset.height(); | |||
this.height = h == null ? null : h.intValue(); | |||
} | |||
} | |||
} |
@@ -22,7 +22,7 @@ public class JMediaInfo { | |||
} | |||
@Getter @Setter private JMedia media; | |||
private boolean emptyMedia() { return media == null || empty(media.getTrack()); } | |||
public boolean emptyMedia() { return media == null || empty(media.getTrack()); } | |||
private final AtomicReference<JFormat> formatRef = new AtomicReference<>(); | |||
@@ -0,0 +1,61 @@ | |||
package jvc.model.js; | |||
import jvc.model.JAsset; | |||
import jvc.model.info.JMediaInfo; | |||
import jvc.model.info.JTrack; | |||
import org.cobbzilla.util.collection.ArrayUtil; | |||
import java.math.BigDecimal; | |||
import static jvc.model.js.JTrackJs.EMPTY_TRACKS; | |||
public class JAssetJs { | |||
public static final JAssetJs[] EMPTY_ASSETS = new JAssetJs[0]; | |||
public Double duration; | |||
public Integer width; | |||
public Integer height; | |||
public JTrackJs[] tracks = EMPTY_TRACKS; | |||
public JTrackJs[] videoTracks = EMPTY_TRACKS; | |||
public JTrackJs[] audioTracks = EMPTY_TRACKS; | |||
public JAssetJs[] assets = EMPTY_ASSETS; | |||
public JAssetJs(JAsset asset) { | |||
final BigDecimal d = asset.duration(); | |||
this.duration = d == null ? null : d.doubleValue(); | |||
final BigDecimal w = asset.width(); | |||
this.width = w == null ? null : w.intValue(); | |||
final BigDecimal h = asset.height(); | |||
this.height = h == null ? null : h.intValue(); | |||
if (asset.hasInfo()) { | |||
final JMediaInfo info = asset.getInfo(); | |||
for (JTrack track : info.getMedia().getTrack()) { | |||
if (!track.audioOrVideo()) continue; | |||
final JTrackJs trackJs = new JTrackJs(track.type().name()); | |||
tracks = ArrayUtil.append(tracks, trackJs); | |||
switch (track.type()) { | |||
case audio: | |||
audioTracks = ArrayUtil.append(audioTracks, trackJs); | |||
break; | |||
case video: | |||
videoTracks = ArrayUtil.append(videoTracks, trackJs); | |||
break; | |||
} | |||
} | |||
} | |||
if (asset.hasListAssets()) { | |||
final JAsset[] list = asset.getList(); | |||
this.assets = new JAssetJs[list.length]; | |||
for (int i = 0; i < assets.length; i++) { | |||
this.assets[i] = new JAssetJs(list[i]); | |||
} | |||
} | |||
} | |||
} |
@@ -0,0 +1,16 @@ | |||
package jvc.model.js; | |||
import lombok.AllArgsConstructor; | |||
import lombok.Getter; | |||
import lombok.NoArgsConstructor; | |||
import lombok.Setter; | |||
import lombok.experimental.Accessors; | |||
@NoArgsConstructor @AllArgsConstructor @Accessors(chain=true) | |||
public class JTrackJs { | |||
public static final JTrackJs[] EMPTY_TRACKS = new JTrackJs[0]; | |||
@Getter @Setter public String type; | |||
} |
@@ -43,7 +43,8 @@ public abstract class SingleOrMultiSourceExecBase<OP extends JSingleSourceOperat | |||
outfile = new File(output.destDirectory(), basename(appendToFileNameBeforeExt(asset.getPath(), "_"+op.shortString()))); | |||
if (outfile.exists()) { | |||
log.info("operate: dest exists: "+abs(outfile)); | |||
return ctx; | |||
assetManager.addOperationAssetSlice(output, subOutput.setPath(abs(outfile))); | |||
continue; | |||
} | |||
} else { | |||
outfile = defaultOutfile; | |||
@@ -60,6 +61,7 @@ public abstract class SingleOrMultiSourceExecBase<OP extends JSingleSourceOperat | |||
process(ctx, op, source, output, output, toolbox, assetManager); | |||
assetManager.addOperationAsset(output); | |||
} | |||
ctx.put("output", output); | |||
return ctx; | |||
} | |||
@@ -48,22 +48,21 @@ public class SplitExec extends ExecBase<SplitOperation> { | |||
if (output.destIsDirectory()) { | |||
outfile = sliceFile(output, formatType, i, incr); | |||
} else { | |||
die("dest exists and is not a directory: "+output.getDest()); | |||
return null; | |||
return die("dest exists and is not a directory: "+output.getDest()); | |||
} | |||
} | |||
} else { | |||
outfile = assetManager.assetPath(op, source, formatType, new Object[]{i, incr}); | |||
} | |||
final JAsset slice = new JAsset(output, outfile); | |||
if (outfile.exists()) { | |||
log.info("operate: outfile exists, not re-creating: "+abs(outfile)); | |||
assetManager.addOperationAssetSlice(output, slice); | |||
continue; | |||
} else { | |||
mkdirOrDie(outfile.getParentFile()); | |||
} | |||
final JAsset slice = new JAsset(output); | |||
slice.setPath(abs(outfile)); | |||
slice.setName(source.getName()+"_"+i+"_"+incr); | |||
ctx.put("output", slice); | |||
@@ -76,7 +75,9 @@ public class SplitExec extends ExecBase<SplitOperation> { | |||
log.debug("operate: command output: "+scriptOutput); | |||
assetManager.addOperationAssetSlice(output, slice); | |||
} | |||
log.info("operate: completed"); | |||
ctx.put("output", output); | |||
return ctx; | |||
} | |||
@@ -5,6 +5,7 @@ import jvc.model.operation.JOperation; | |||
import jvc.model.operation.JValidationResult; | |||
import jvc.operation.exec.ExecBase; | |||
import lombok.Getter; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.util.javascript.JsEngine; | |||
import java.util.ArrayList; | |||
@@ -13,6 +14,7 @@ import java.util.List; | |||
import java.util.Map; | |||
import java.util.stream.Collectors; | |||
@Slf4j | |||
public class JvcEngine { | |||
private final Toolbox toolbox; | |||
@@ -38,7 +40,12 @@ public class JvcEngine { | |||
.setExecIndex(completed.size()) | |||
.setNoExec(noExec) | |||
.getExec(); | |||
final Map<String, Object> ctx = exec.operate(op, toolbox, assetManager); | |||
if (ctx == null) { | |||
log.debug("runOp("+op.getOperation()+"): noop"); | |||
return; | |||
} | |||
if (op.hasValidate()) { | |||
final JsEngine js = toolbox.getJs(); | |||
@@ -14,15 +14,23 @@ | |||
"creates": "v2", | |||
"source": "vid2", | |||
"start": "10", | |||
"end": "30" | |||
"end": "30", | |||
"validate": [{ | |||
"comment": "expect output to be about 20 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 20, 0.1)" | |||
}] | |||
}, | |||
{ | |||
"operation": "adjust-speed", // name of the operation | |||
"creates": "quickened", // output asset name | |||
"source": "v2", // main video asset | |||
"factor": "4", // factor=1 is no change, factor>1 is faster, factor<1 is slower | |||
"audio": "silent" // audio: silent (default), unchanged, match | |||
"audio": "silent", // audio: silent (default), unchanged, match | |||
// if audio is match, then factor must be between 0.5 and 100 | |||
"validate": [{ | |||
"comment": "expect output to be about 5 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 5, 0.1)" | |||
}] | |||
} | |||
] | |||
} |
@@ -10,12 +10,20 @@ | |||
"creates": { | |||
"name": "combined_vid1", // name of the output asset | |||
"dest": "src/test/resources/outputs/combined1.mp4" // asset will be written to this file | |||
} | |||
}, | |||
"validate": [{ | |||
"comment": "expect output to be about 40 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 40, 0.1)" | |||
}] | |||
}, | |||
{ | |||
"operation": "concat", // name of the operation | |||
"sources": ["vid1_splits[1..2]"], // concatentate these sources -- the 2nd and 3rd files only | |||
"creates": "combined_vid2" // name of the output asset, will be written to scratch directory | |||
"creates": "combined_vid2", // name of the output asset, will be written to scratch directory | |||
"validate": [{ | |||
"comment": "expect output to be about 20 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 20, 0.1)" | |||
}] | |||
} | |||
] | |||
} |
@@ -19,7 +19,14 @@ | |||
"x": "source.width * 0.6", // pan to this x-position | |||
"y": "source.height * 0.4", // pan to this y-position | |||
"width": "1024", // width of output video | |||
"height": "768" // height of output video | |||
"height": "768", // height of output video | |||
"validate": [{ | |||
"comment": "expect output to be about 5 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 5, 0.1)" | |||
}, { | |||
"comment": "expect output resolution of 1024x768", | |||
"test": "output.width === 1024 && output.height === 768" | |||
}] | |||
} | |||
] | |||
} |
@@ -1,6 +1,6 @@ | |||
{ | |||
"assets": [ | |||
// wildcard matches multiple files, vid1_splits becomes a "list" asset | |||
// wildcard matches multiple files, vid1_splits becomes a "list" asset. resolution is 320x240 | |||
{ "name": "vid1_splits", "path": "src/test/resources/outputs/vid1_splits_*.mp4" } | |||
], | |||
"operations": [ | |||
@@ -10,7 +10,17 @@ | |||
"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" // letterbox padding color | |||
"color": "AliceBlue", // letterbox padding color | |||
"validate": [{ | |||
"comment": "expect output resolution with double source width", | |||
"test": "output.width === 2 * source.width" | |||
}, { | |||
"comment": "expect output resolution with same height as source", | |||
"test": "output.height === source.height" | |||
}, { | |||
"comment": "expect output resolution of 640x240", | |||
"test": "output.width === 640 && output.height === 240" | |||
}] | |||
}, | |||
{ | |||
"operation": "letterbox", // name of the operation | |||
@@ -18,7 +28,17 @@ | |||
"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" // letterbox padding color | |||
"color": "DarkCyan", // letterbox padding color | |||
"validate": [{ | |||
"comment": "expect output resolution with double source height", | |||
"test": "output.height === 2 * source.height" | |||
}, { | |||
"comment": "expect output resolution with same width as source", | |||
"test": "output.width === source.width" | |||
}, { | |||
"comment": "expect output resolution of 320x480", | |||
"test": "output.width === 320 && output.height === 480" | |||
}] | |||
} | |||
] | |||
} |
@@ -20,14 +20,21 @@ | |||
"creates": "v2", | |||
"source": "vid2", | |||
"start": "10", | |||
"end": "30" | |||
"end": "30", | |||
"validate": [{ | |||
"comment": "expect output to be about 20 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 20, 0.1)" | |||
}] | |||
}, | |||
{ | |||
"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) | |||
"at": "5", // when (on the video timeline) to start playing the audio. default is 0 (beginning) | |||
"validate": [ | |||
// todo: how to validate audio was inserted? | |||
] | |||
} | |||
] | |||
} |
@@ -20,7 +20,11 @@ | |||
"creates": "v1", | |||
"source": "vid1", | |||
"start": "0", | |||
"end": "60" | |||
"end": "60", | |||
"validate": [{ | |||
"comment": "expect output to be about 60 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 60, 0.1)" | |||
}] | |||
}, | |||
{ | |||
// overlay video is 10 seconds long | |||
@@ -28,7 +32,11 @@ | |||
"creates": "v2", | |||
"source": "vid2", | |||
"start": "10", | |||
"end": "20" | |||
"end": "20", | |||
"validate": [{ | |||
"comment": "expect output to be about 10 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 10, 0.1)" | |||
}] | |||
}, | |||
{ | |||
"operation": "overlay", // name of the operation | |||
@@ -49,7 +57,10 @@ | |||
"height": "source.height", // how tall the overlay will be, in pixels. default is the full overlay height, or maintain aspect ratio if width was set | |||
"x": "source.width / 2", // horizontal overlay position on main video. default is 0 | |||
"y": "source.height / 2" // vertical overlay position on main video. default is 0 | |||
} | |||
}, | |||
"validate": [ | |||
// todo: how to validate overlay was added? | |||
] | |||
} | |||
] | |||
} |
@@ -14,19 +14,37 @@ | |||
"creates": "v2", | |||
"source": "vid2", | |||
"start": "0", | |||
"end": "20" | |||
"end": "20", | |||
"validate": [{ | |||
"comment": "expect output to be about 20 seconds long, give or take 0.1 seconds", | |||
"test": "is_within(output.duration, 20, 0.1)" | |||
}] | |||
}, | |||
{ | |||
"operation": "remove-track", // name of the operation | |||
"creates": "vid2_video_only", // name of the output asset | |||
"source": "v2", // main video asset | |||
"track": "audio" // remove all audio tracks | |||
"track": "audio", // remove all audio tracks | |||
"validate": [{ | |||
"comment": "expect only one track", | |||
"test": "output.tracks.length === 1" | |||
}, { | |||
"comment": "expect only track to be video", | |||
"test": "output.videoTracks.length === 1" | |||
}] | |||
}, | |||
{ | |||
"operation": "remove-track", // name of the operation | |||
"creates": "vid2_audio_only", // name of the output asset | |||
"source": "v2", // main video asset | |||
"track": "video" // remove all video tracks | |||
"track": "video", // remove all video tracks | |||
"validate": [{ | |||
"comment": "expect only one track", | |||
"test": "output.tracks.length === 1" | |||
}, { | |||
"comment": "expect only track to be audio", | |||
"test": "output.audioTracks.length === 1" | |||
}] | |||
}, | |||
{ | |||
"operation": "remove-track", // name of the operation | |||
@@ -36,7 +54,17 @@ | |||
// only remove the first audio track | |||
"type": "audio", // track type to remove | |||
"number": "0" // track number to remove | |||
} | |||
}, | |||
"validate": [{ | |||
"comment": "expect one less track", | |||
"test": "output.tracks.length === source.tracks.length - 1" | |||
}, { | |||
"comment": "expect one less audio track", | |||
"test": "output.audioTracks.length === source.audioTracks.length - 1" | |||
}, { | |||
"comment": "expect video tracks unchanged", | |||
"test": "output.videoTracks.length === source.videoTracks.length" | |||
}] | |||
} | |||
] | |||
} |
@@ -1,6 +1,6 @@ | |||
{ | |||
"assets": [ | |||
// wildcard matches multiple files, vid1_splits becomes a "list" asset | |||
// wildcard matches multiple files, vid1_splits becomes a "list" asset. resolution is 320x240 | |||
{ "name": "vid1_splits", "path": "src/test/resources/outputs/vid1_splits_*.mp4" } | |||
], | |||
"operations": [ | |||
@@ -9,13 +9,24 @@ | |||
"creates": "scaled_test1", // output asset name | |||
"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 | |||
"height": "768", // height of scaled asset. if omitted and width is present, height will be proportional | |||
"validate": [{ | |||
"comment": "expect output resolution of 1024x768", | |||
"test": "output.width === 1024 && output.height === 768" | |||
}] | |||
}, | |||
{ | |||
"operation": "scale", // name of the operation | |||
"creates": "scaled_small", // asset it creates | |||
"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. | |||
"validate": [{ | |||
"comment": "expect output resolution that is half of source", | |||
"test": "output.width === source.width/2 && output.height === source.height/2" | |||
}, { | |||
"comment": "expect output resolution that is 160x120", | |||
"test": "output.width === 160 && output.height === 120" | |||
}] | |||
} | |||
] | |||
} |
@@ -1,6 +1,6 @@ | |||
{ | |||
"assets": [ | |||
// these are US government videos not covered by copyright | |||
// these are US government videos not covered by copyright. resolution is 320x240 | |||
{ | |||
"name": "vid1", | |||
"path": "https://archive.org/download/gov.archives.arc.1257628/gov.archives.arc.1257628_512kb.mp4", | |||
@@ -17,7 +17,17 @@ | |||
"source": "vid1", // split this source asset | |||
"interval": "10", // split every ten seconds | |||
"start": "65", // start one minute and five seconds into the video | |||
"end": "100" // end 100 seconds into the video | |||
"end": "100", // end 100 seconds into the video | |||
"validate": [{ | |||
"comment": "expect output contain 4 assets", | |||
"test": "output.assets.length === 4" | |||
}, { | |||
"comment": "expect first asset to be about 10 seconds long", | |||
"test": "is_within(output.assets[0].duration, 10, 0.1)" | |||
}, { | |||
"comment": "expect last asset to be about 10 seconds long", | |||
"test": "is_within(output.assets[output.assets.length-1].duration, 10, 0.1)" | |||
}] | |||
} | |||
] | |||
} |
@@ -12,7 +12,17 @@ | |||
}, | |||
"source": "vid1_splits", // trim these source assets | |||
"start": "1", // cropped region starts here, default is zero | |||
"end": "6" // cropped region ends here, default is end of video | |||
"end": "6", // cropped region ends here, default is end of video | |||
"validate": [{ | |||
"comment": "expect output contain 4 assets", | |||
"test": "output.assets.length === 4" | |||
}, { | |||
"comment": "expect first asset to be about 5 seconds long", | |||
"test": "is_within(output.assets[0].duration, 5, 0.1)" | |||
}, { | |||
"comment": "expect last asset to be about 5 seconds long", | |||
"test": "is_within(output.assets[output.assets.length-1].duration, 5, 0.1)" | |||
}] | |||
} | |||
] | |||
} |