瀏覽代碼

initial commit

master
Jonathan Cobb 3 年之前
當前提交
2d347781b6
共有 30 個文件被更改,包括 1359 次插入0 次删除
  1. +9
    -0
      .gitignore
  2. +3
    -0
      .gitmodules
  3. +109
    -0
      README.md
  4. +33
    -0
      bin/jvcl
  5. +171
    -0
      pom.xml
  6. +44
    -0
      src/main/java/jvcl/main/Jvcl.java
  7. +43
    -0
      src/main/java/jvcl/main/JvclOptions.java
  8. +10
    -0
      src/main/java/jvcl/model/JArtifact.java
  9. +135
    -0
      src/main/java/jvcl/model/JAsset.java
  10. +23
    -0
      src/main/java/jvcl/model/JFileExtension.java
  11. +69
    -0
      src/main/java/jvcl/model/JFormat.java
  12. +25
    -0
      src/main/java/jvcl/model/JOperation.java
  13. +31
    -0
      src/main/java/jvcl/model/JOperationType.java
  14. +16
    -0
      src/main/java/jvcl/model/JSpec.java
  15. +11
    -0
      src/main/java/jvcl/model/info/JMedia.java
  16. +69
    -0
      src/main/java/jvcl/model/info/JMediaInfo.java
  17. +92
    -0
      src/main/java/jvcl/model/info/JTrack.java
  18. +11
    -0
      src/main/java/jvcl/model/info/JTrackType.java
  19. +88
    -0
      src/main/java/jvcl/op/ConcatOperation.java
  20. +92
    -0
      src/main/java/jvcl/op/SplitOperation.java
  21. +13
    -0
      src/main/java/jvcl/op/TrimOperation.java
  22. +92
    -0
      src/main/java/jvcl/service/AssetManager.java
  23. +9
    -0
      src/main/java/jvcl/service/JOperator.java
  24. +18
    -0
      src/main/java/jvcl/service/OperationEngine.java
  25. +67
    -0
      src/main/java/jvcl/service/Toolbox.java
  26. +28
    -0
      src/main/resources/logback.xml
  27. +21
    -0
      src/test/java/javicle/test/SplitTest.java
  28. +2
    -0
      src/test/resources/sources/.gitignore
  29. +24
    -0
      src/test/resources/tests/test_split.json
  30. +1
    -0
      utils/cobbzilla-utils

+ 9
- 0
.gitignore 查看文件

@@ -0,0 +1,9 @@
*.iml
.idea
target
tmp
logs
dependency-reduced-pom.xml
*~
*.log
*.mp4

+ 3
- 0
.gitmodules 查看文件

@@ -0,0 +1,3 @@
[submodule "utils/cobbzilla-utils"]
path = utils/cobbzilla-utils
url = git@git.bubblev.org:bubblev/cobbzilla-utils.git

+ 109
- 0
README.md 查看文件

@@ -0,0 +1,109 @@
# Javicle - a JSON Video Composition Language

Javicle is not a replacement for Final Cut Pro or even iMovie.

Javicle might be right for you if your video composition and manipulation needs are relatively simple
and you enjoy doing things with the command line and some JSON config instead of a GUI.

This also give you the ability to track more of your workflow in source control - if you commit all
the original assets and the .jvcl files that describe how to create the output assets, you don't need
to save/archive the output assets anywhere.

Note, for those who want truly 100% re-creatable builds, you would also need to record the versions
of the various tools used (ffmpeg, etc) and reuse those same versions when recreating a build. This is
generally overkill though, since the options we use on the different tools have been stable for a while
and I see a low likelihood of a significant change in behavior, or a bug being introduced.

In JVCL there are two main concepts: assets and operations.

Assets are the inputs - generally image, audio and video files. Assets have a name and a path.
The path can be a file or a URL.

Operations are transformations to perform on the inputs.
An operation can produce a new intermediate asset.
Intermediate assets have names, and special paths that indicate how to reconstruct them from their assets, such that if you have the path of an intermediate asset, you can recreate its content, assuming you supply the same input assets.

## Operations

### split
Split an audio/video asset into multiple assets

### concat
Concatenate audio/video assets together into one asset

### trim
Trim audio/video - crop from beginning, end, or both

### overlay
Overlay one video file onto another

### ken-burns
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

## Example
```json
{
"assets": [
{"name": "vid1", "path": "/tmp/path/to/video1.mp4"},
{"name": "vid2", "path": "/tmp/path/to/video2.mp4"}
],
"operations": [
{
"operation": "split", // name of the operation
"creates": "vid1_split_%", // assets it creates, the '%' will be replaced with a counter
"perform": {
"split": "vid1", // split this source asset
"interval": "10s" // split every ten seconds
}
},
{
"operation": "concat", // name of the operation
"creates": "recombined_vid1", // assets it creates, the '%' will be replaced with a counter
"perform": {
"concat": ["vid1_split"] // recombine all split assets
}
},
{
"operation": "concat", // name of the operation
"creates": "combined_vid", // asset it creates, can be referenced later
"perform": {
"concat": ["vid1", "vid2"] // operation-specific: this says, concatenate these named assets
}
},
{
"operation": "concat", // name of the operation
"creates": "combined_vid", // the asset it creates, can be referenced later
"perform": {
"concat": ["vid1", "vid2"] // operation-specific: this says, concatenate these named assets
}
},
{
"operation": "overlay", // name of the operation
"creates": "overlay1", // asset it creates
"perform": {
"source": "combined_vid1", // main video asset
"overlay": "vid1", // overlay this video on the main video

"start": "vid1.end_ts", // when (on the main video timeline) to start the overlay. default is 0 (beginning)
"duration": "vid1.duration", // how long to play the overlay. default is to play the entire overlay asset

"width": 400, // how wide the overlay will be, in pixels. default is "overlay.width"
"height": 300, // how tall the overlay will be, in pixels. default is "overlay.height"

"x": "source.width / 2", // horizontal overlay position. default is 0
"y": "source.height / 2", // vertical overlay position. default is 0
"out": "1080p", // this is a shortcut to the two lines below, and is the preferred way of specifying the output resolution
"out_width": 1920, // output width in pixels. default is source width
"out_height": 1024 // output height in pixes. default is source height
}
}
]
}
```

+ 33
- 0
bin/jvcl 查看文件

@@ -0,0 +1,33 @@
#!/bin/bash
#
# Run JVCL on a spec
#
# Usage:
#
# jvcl [-f spec-file] [-t temp-dir]
#
# If the `-f` option is omitted and no spec-file is provided, then jvcl will try to read
# a spec from stdin.
#
# Every run of JVCL will create a temp directory. You can either specify the name of the directory
# with `-t` (it will be created if it does not exist), or let jvcl create a temp directory for you.
#
# Note: this command will build the jvcl jar file if needed. The first time you run it, it might
# take a long time to start up.
#
function die () {
echo 1>&2 "${1}"
exit 1
}

JVCL_DIR="$(cd "$(dirname "${0}")"/.. && pwd)"
JVCL_JAR="$(find "${JVCL_DIR}"/target -type f -name "jvcl-*.jar" | head -1)"
if [[ -z "${JVCL_JAR}" ]] ; then
mvn -DskipTests=true clean package || die "Error building JVCL jar"
JVCL_JAR="$(find "${JVCL_DIR}"/target -type f -name "jvcl-*.jar" | head -1)"
if [[ -z "${JVCL_JAR}" ]] ; then
die "No JVCL jar file found after successful build"
fi
fi

java -cp "${JVCL_JAR}" jvcl.main.Jvcl "${@}"

+ 171
- 0
pom.xml 查看文件

@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="UTF-8"?>

<!--
(c) Copyright 2020 Jonathan Cobb
javicle is available under the Apache License, version 2: http://www.apache.org/licenses/LICENSE-2.0.html
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.cobbzilla</groupId>
<artifactId>javicle</artifactId>
<name>javicle</name>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>

<licenses>
<license>
<name>The Apache Software License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
</licenses>

<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>

<properties>
<args4j.version>2.0.23</args4j.version>
<graalvm.version>19.2.0</graalvm.version>
<jackson.version>2.11.2</jackson.version>
<commons-exec.version>1.3</commons-exec.version>
<httpcore.version>4.4.13</httpcore.version>
<httpclient.version>4.5.13</httpclient.version>
<httpmime.version>4.5.13</httpmime.version>
<joda-time.version>2.10.8</joda-time.version>
<slf4j.version>1.7.30</slf4j.version>
<logback.version>1.2.3</logback.version>
<handlebars.version>4.2.0</handlebars.version>
<junit.version>4.13.1</junit.version>
</properties>

<profiles>
<profile>
<id>uberjar</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.1</version>
<configuration>
<finalName>zilla-utils</finalName>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>jvcl.main.Jvcl</mainClass>
</transformer>
</transformers>
<!-- Exclude signed jars to avoid errors
see: http://stackoverflow.com/a/6743609/1251543
-->
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>

<dependencies>
<dependency>
<groupId>org.cobbzilla</groupId>
<artifactId>cobbzilla-utils</artifactId>
<version>2.0.1</version>
<exclusions>
<exclusion>
<groupId>org.apache.poi</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>fr.opensagres.xdocreport</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>com.codeborne</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>jtidy</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>xalan</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>net.sf.saxon</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.ant</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>com.opencsv</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>org.quartz-scheduler</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.pdfbox</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>org.atteo</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>io.github.bonigarcia</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<source>11</source>
<target>11</target>
<showWarnings>true</showWarnings>
</configuration>
</plugin>
</plugins>
</build>

</project>

+ 44
- 0
src/main/java/jvcl/main/Jvcl.java 查看文件

@@ -0,0 +1,44 @@
package jvcl.main;


import jvcl.model.JSpec;
import jvcl.service.AssetManager;
import jvcl.service.OperationEngine;
import jvcl.service.Toolbox;
import org.cobbzilla.util.main.BaseMain;

import java.util.Arrays;

import static org.cobbzilla.util.daemon.ZillaRuntime.empty;
import static org.cobbzilla.util.json.JsonUtil.json;

public class Jvcl extends BaseMain<JvclOptions> {

public static void main (String[] args) { main(Jvcl.class, args); }

@Override protected void run() throws Exception {
final JvclOptions options = getOptions();
final JSpec spec = options.getSpec();

if (empty(spec.getAssets())) {
err(">>> jvcl: no assets defined in spec");
return;
}
if (empty(spec.getOperations())) {
err(">>> jvcl: no operations defined in spec");
return;
}

final Toolbox toolbox = Toolbox.DEFAULT_TOOLBOX;

final AssetManager assetManager = new AssetManager(toolbox, getOptions().getScratchDir());
Arrays.stream(spec.getAssets()).forEach(assetManager::defineAsset);

final OperationEngine opEngine = new OperationEngine(toolbox, assetManager);
Arrays.stream(spec.getOperations()).forEach(opEngine::perform);

err(">>> jvcl: completed " + spec.getOperations().length + " operations");
out(json(assetManager.getAssets()));
}

}

+ 43
- 0
src/main/java/jvcl/main/JvclOptions.java 查看文件

@@ -0,0 +1,43 @@
package jvcl.main;

import jvcl.model.JSpec;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.cobbzilla.util.main.BaseMainOptions;
import org.kohsuke.args4j.Option;

import java.io.File;

import static org.cobbzilla.util.daemon.ZillaRuntime.die;
import static org.cobbzilla.util.daemon.ZillaRuntime.readStdin;
import static org.cobbzilla.util.io.FileUtil.abs;
import static org.cobbzilla.util.io.FileUtil.toStringOrDie;
import static org.cobbzilla.util.json.JsonUtil.FULL_MAPPER_ALLOW_COMMENTS;
import static org.cobbzilla.util.json.JsonUtil.json;

@Slf4j
public class JvclOptions extends BaseMainOptions {

public static final String USAGE_SPEC = "Spec file to run. Default is to read from stdin.";
public static final String OPT_SPEC = "-f";
public static final String LONGOPT_SPEC = "--file";
@Option(name=OPT_SPEC, aliases=LONGOPT_SPEC, usage=USAGE_SPEC)
@Getter @Setter private File specFile;

public JSpec getSpec() {
if (specFile != null && !specFile.getName().equals("-")) {
if (!specFile.exists()) return die("File not found: "+abs(specFile));
return json(toStringOrDie(specFile), JSpec.class, FULL_MAPPER_ALLOW_COMMENTS);
}
log.info("reading JVCL spec from stdin...");
return json(readStdin(), JSpec.class);
}

public static final String USAGE_SCRATCH_DIR = "Scratch directory. Default is to create a temp directory under /tmp";
public static final String OPT_SCRATCH_DIR = "-t";
public static final String LONGOPT_SCRATCH_DIR = "--temp-dir";
@Option(name=OPT_SCRATCH_DIR, aliases=LONGOPT_SCRATCH_DIR, usage=USAGE_SCRATCH_DIR)
@Getter @Setter private File scratchDir;

}

+ 10
- 0
src/main/java/jvcl/model/JArtifact.java 查看文件

@@ -0,0 +1,10 @@
package jvcl.model;

import lombok.Getter;
import lombok.Setter;

public class JArtifact {

@Getter @Setter private String name;
@Getter @Setter private String file;
}

+ 135
- 0
src/main/java/jvcl/model/JAsset.java 查看文件

@@ -0,0 +1,135 @@
package jvcl.model;

import com.fasterxml.jackson.databind.JsonNode;
import jvcl.model.info.JMediaInfo;
import jvcl.service.AssetManager;
import jvcl.service.Toolbox;
import lombok.*;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.cobbzilla.util.collection.ArrayUtil;
import org.cobbzilla.util.http.HttpUtil;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;

import static org.cobbzilla.util.daemon.ZillaRuntime.*;
import static org.cobbzilla.util.http.HttpSchemes.isHttpOrHttps;
import static org.cobbzilla.util.io.FileUtil.abs;
import static org.cobbzilla.util.io.StreamUtil.loadResourceAsStream;
import static org.cobbzilla.util.json.JsonUtil.json;
import static org.cobbzilla.util.reflect.ReflectionUtil.copy;

@NoArgsConstructor @AllArgsConstructor @Accessors(chain=true) @Slf4j
public class JAsset {

public static final JAsset NULL_ASSET = new JAsset().setName("~null asset~").setPath("/dev/null");
public static final String PREFIX_CLASSPATH = "classpath:";

public JAsset(JAsset other) { copy(this, other); }

@Getter @Setter private String name;
@Getter @Setter private String path;

// an asset can specify where its file should live
// if the file already exists, it is used and not overwritten
@Getter @Setter private String dest;
public boolean hasDest() { return !empty(dest); }
public boolean destExists() { return new File(dest).exists(); }

// if path was not a file, it got resolved to a file
// the original value of 'path' is stored here
@Getter @Setter private String originalPath;

@Getter @Setter private JAsset[] list;
public boolean hasList () { return list != null; }
public void addAsset(JAsset slice) { list = ArrayUtil.append(list, slice); }

@Getter @Setter private JFormat format;
public boolean hasFormat() { return format != null; }

@Getter private JMediaInfo info;
public JAsset setInfo (JMediaInfo info) {
this.info = info;
setFormat(info.getFormat());
return this;
}
public boolean hasInfo() { return info != null; }

public static JAsset json2asset(JsonNode node) {
if (node.isObject()) return json(node, JAsset.class);
if (node.isTextual()) return new JAsset().setName(node.textValue());
return die("json2asset: node was neither a JSON object nor a string: "+json(node));
}

public void mergeFormat(JFormat format) {
if (format == null) {
log.warn("mergeFormat: cannot merge null");
return;
}
if (this.format == null) {
this.format = new JFormat(format);
} else {
this.format.merge(format);
}
}

public BigDecimal duration() { return getInfo().duration(); }

public JAsset init(AssetManager assetManager, Toolbox toolbox) {
initPath(assetManager);
setInfo(toolbox.getInfo(this));
return this;
}

private JAsset initPath(AssetManager assetManager) {
final String path = getPath();
if (empty(path)) return die("initPath: no path!");

// if dest already exists, use that
if (hasDest() && destExists()) {
setOriginalPath(path);
setPath(getDest());
return this;
}

final File sourcePath = hasDest() ? new File(getDest()) : assetManager.sourcePath(getName());
if (path.startsWith(PREFIX_CLASSPATH)) {
// it's a classpath resource
final String resource = path.substring(PREFIX_CLASSPATH.length());
try {
@Cleanup final InputStream in = loadResourceAsStream(resource);
@Cleanup final OutputStream out = new FileOutputStream(sourcePath);
IOUtils.copyLarge(in, out);
setOriginalPath(path);
setPath(abs(sourcePath));
} catch (Exception e) {
return die("initPath: error loading classpath resource "+resource+" : "+shortError(e));
}

} else if (isHttpOrHttps(path)) {
// it's a URL
try {
@Cleanup final InputStream in = HttpUtil.get(path);
@Cleanup final OutputStream out = new FileOutputStream(sourcePath);
IOUtils.copyLarge(in, out);
setOriginalPath(path);
setPath(abs(sourcePath));
} catch (Exception e) {
return die("initPath: error loading URL resource "+path+" : "+shortError(e));
}

} else {
// must be a file
final File f = new File(path);
if (!f.exists()) return die("initPath: file path does not exist: "+path);
if (!f.canRead()) return die("initPath: file path is not readable: "+path);
}
return this;
}

}

+ 23
- 0
src/main/java/jvcl/model/JFileExtension.java 查看文件

@@ -0,0 +1,23 @@
package jvcl.model;

import com.fasterxml.jackson.annotation.JsonCreator;
import lombok.AllArgsConstructor;

@AllArgsConstructor
public enum JFileExtension {

mp4 (".mp4"),
mkv (".mkv"),
raw (".yuv");

@JsonCreator public static JFileExtension fromString(String v) { return valueOf(v.toLowerCase()); }

public static boolean isValid(String v) {
try { fromString(v); return true; } catch (Exception ignored) {}
return false;
}

private final String ext;
public String ext() { return ext; }

}

+ 69
- 0
src/main/java/jvcl/model/JFormat.java 查看文件

@@ -0,0 +1,69 @@
package jvcl.model;

import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;

import java.util.Arrays;
import java.util.Optional;

import static org.cobbzilla.util.daemon.ZillaRuntime.die;
import static org.cobbzilla.util.daemon.ZillaRuntime.empty;
import static org.cobbzilla.util.json.JsonUtil.json;
import static org.cobbzilla.util.reflect.ReflectionUtil.copy;

@NoArgsConstructor @Accessors(chain=true)
public class JFormat {

@Getter @Setter private Integer height;
public boolean hasHeight () { return height != null; }

@Getter @Setter private Integer width;
public boolean hasWidth () { return width != null; }

@Getter @Setter private JFileExtension fileExtension;
public boolean hasFileExtension() { return fileExtension != null; }

public JFormat(JFormat format) { copy(this, format); }

public void merge(JFormat other) {
if (!hasHeight()) setHeight(other.getHeight());
if (!hasWidth()) setWidth(other.getWidth());
if (!hasFileExtension()) setFileExtension(other.getFileExtension());
}

public static JFormat getFormat(JsonNode formatNode, JAsset[] sources) {
if (formatNode == null) {
// no format supplied, use format from first source
return new JFormat().setFileExtension(sources[0].getFormat().getFileExtension());
}
if (formatNode.isObject()) {
return json(formatNode, JFormat.class);

} else if (formatNode.isTextual()) {
final JFileExtension formatType;
final String formatTypeString = formatNode.textValue();
if (!empty(formatTypeString)) {
// is the format the name of an input?
final Optional<JAsset> asset = Arrays.stream(sources).filter(s -> s.getName().equals(formatTypeString)).findFirst();
if (asset.isEmpty()) {
// not the name of an asset, must be the name of a format
formatType = JFileExtension.valueOf(formatTypeString);
} else {
// it's the name of an asset, use that asset's format
formatType = asset.get().getFormat().getFileExtension();
}
return new JFormat().setFileExtension(formatType);
} else {
// is the format a valid format type?
if (JFileExtension.isValid(formatTypeString)) return new JFormat().setFileExtension(JFileExtension.valueOf(formatTypeString));
return die("getFormat: invalid format type: "+formatTypeString);
}
} else {
return die("getFormat: invalid format node: "+json(formatNode));
}
}

}

+ 25
- 0
src/main/java/jvcl/model/JOperation.java 查看文件

@@ -0,0 +1,25 @@
package jvcl.model;

import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;

import static org.cobbzilla.util.daemon.ZillaRuntime.hashOf;
import static org.cobbzilla.util.json.JsonUtil.json;

@NoArgsConstructor @Accessors(chain=true)
public class JOperation {

@Getter @Setter private JOperationType operation;
@Getter @Setter private JsonNode creates;
@Getter @Setter private JsonNode perform;

public String hash(JAsset[] sources) { return hash(sources, null); }

public String hash(JAsset[] sources, Object[] args) {
return hashOf(getOperation(), json(creates), json(perform), json(sources), args);
}

}

+ 31
- 0
src/main/java/jvcl/model/JOperationType.java 查看文件

@@ -0,0 +1,31 @@
package jvcl.model;

import com.fasterxml.jackson.annotation.JsonCreator;
import jvcl.op.ConcatOperation;
import jvcl.op.SplitOperation;
import jvcl.op.TrimOperation;
import jvcl.service.AssetManager;
import jvcl.service.JOperator;
import jvcl.service.Toolbox;
import lombok.AllArgsConstructor;

@AllArgsConstructor
public enum JOperationType {

concat (new ConcatOperation()),
split (new SplitOperation()),
trim (new TrimOperation()),
overlay (null),
ken_burns (null),
letterbox (null),
split_silence (null);

@JsonCreator public static JOperationType fromString(String v) { return valueOf(v.toLowerCase().replace("-", "_")); }

private final JOperator operator;

public void perform(JOperation op, Toolbox toolbox, AssetManager assetManager) {
operator.operate(op, toolbox, assetManager);
}

}

+ 16
- 0
src/main/java/jvcl/model/JSpec.java 查看文件

@@ -0,0 +1,16 @@
package jvcl.model;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;

@NoArgsConstructor @Accessors(chain=true)
public class JSpec {

@Getter @Setter private JAsset[] assets;
@Getter @Setter private JOperation[] operations;
@Getter @Setter private JArtifact[] artifacts;

}

+ 11
- 0
src/main/java/jvcl/model/info/JMedia.java 查看文件

@@ -0,0 +1,11 @@
package jvcl.model.info;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import lombok.Setter;

public class JMedia {

@JsonProperty("@ref") @Getter @Setter private String ref;
@Getter @Setter private JTrack[] track;
}

+ 69
- 0
src/main/java/jvcl/model/info/JMediaInfo.java 查看文件

@@ -0,0 +1,69 @@
package jvcl.model.info;

import jvcl.model.JFormat;
import jvcl.model.JFileExtension;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

import java.math.BigDecimal;

import static org.cobbzilla.util.daemon.ZillaRuntime.big;
import static org.cobbzilla.util.daemon.ZillaRuntime.empty;

@Slf4j
public class JMediaInfo {

@Getter @Setter private JMedia media;
@Getter(lazy=true) private final JFormat format = initFormat();
public boolean hasFormat () { return getFormat() != null; }

private JFormat initFormat () {
if (media == null || empty(media.getTrack())) return null;
JTrack general = null;
JTrack video = null;
JTrack audio = null;
for (int i=0; i<media.getTrack().length; i++) {
final JTrack t = media.getTrack()[i];
if (t.video()) {
if (video == null) {
video = t;
} else {
log.warn("initFormat: multiple video tracks found, only using the first one");
}
} else if (t.audio()) {
if (audio == null) {
audio = t;
} else {
log.warn("initFormat: multiple audio tracks found, only using the first one");
}
} else if (t.getType().equals("General") && general == null) {
general = t;
}
}
final JFormat format = new JFormat();
if (video != null) {
format.setFileExtension(JFileExtension.fromString(general.getFileExtension()))
.setHeight(video.height())
.setWidth(video.width());
} else if (audio != null) {
format.setFileExtension(JFileExtension.fromString(audio.getFileExtension()));
}
return format;
}

public BigDecimal duration() {
if (media == null || empty(media.getTrack())) return BigDecimal.ZERO;

// find the longest media track
BigDecimal longest = null;
for (JTrack t : media.getTrack()) {
if (!t.media()) continue;
if (!t.hasDuration()) continue;
final BigDecimal d = big(t.getDuration());
if (longest == null || longest.compareTo(d) < 0) longest = d;
}
return longest;
}

}

+ 92
- 0
src/main/java/jvcl/model/info/JTrack.java 查看文件

@@ -0,0 +1,92 @@
package jvcl.model.info;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.Setter;

import static java.lang.Integer.parseInt;
import static org.cobbzilla.util.daemon.ZillaRuntime.empty;

public class JTrack {

@JsonProperty("@type") @Getter @Setter private String type;
public JTrackType type() {
try {
return JTrackType.fromString(type);
} catch (Exception e) {
return JTrackType.other;
}
}
public boolean audio() { return type() == JTrackType.audio; }
public boolean video() { return type() == JTrackType.video; }
public boolean media() { return audio() || video(); }

@JsonProperty("ID") @Getter @Setter private String id;
@JsonProperty("StreamOrder") @Getter @Setter private String streamOrder;
@JsonProperty("VideoCount") @Getter @Setter private String videoCount;
@JsonProperty("AudioCount") @Getter @Setter private String audioCount;
@JsonProperty("FileExtension") @Getter @Setter private String fileExtension;
@JsonProperty("Format") @Getter @Setter private String format;
@JsonProperty("Format_AdditionalFeatures") @Getter @Setter private String formatAdditionalFeatures;
@JsonProperty("Format_Profile") @Getter @Setter private String formatProfile;
@JsonProperty("Format_Level") @Getter @Setter private String formatLevel;
@JsonProperty("Channels") @Getter @Setter private String channels;
@JsonProperty("ChannelPositions") @Getter @Setter private String channelPositions;
@JsonProperty("ChannelLayout") @Getter @Setter private String channelLayout;
@JsonProperty("SamplesPerFrame") @Getter @Setter private String samplesPerFrame;
@JsonProperty("SamplingRate") @Getter @Setter private String samplingRate;
@JsonProperty("SamplingCount") @Getter @Setter private String samplingCount;
@JsonProperty("Compression_Mode") @Getter @Setter private String compressionMode;
@JsonProperty("BitRate") @Getter @Setter private String bitrate;

@JsonProperty("Width") @Getter @Setter private String width;
public Integer width () { return parseInt(width); }

@JsonProperty("Height") @Getter @Setter private String height;
public Integer height () { return parseInt(height); }

@JsonProperty("Sampled_Width") @Getter @Setter private String sampledWidth;
@JsonProperty("Sampled_Height") @Getter @Setter private String sampledHeight;
@JsonProperty("PixelAspectRatio") @Getter @Setter private String pixelAspectRatio;
@JsonProperty("DisplayAspectRatio") @Getter @Setter private String displayAspectRatio;
@JsonProperty("Rotation") @Getter @Setter private String rotation;
@JsonProperty("CodecID") @Getter @Setter private String codecID;
@JsonProperty("CodecID_Compatible") @Getter @Setter private String codecIDCompatible;
@JsonProperty("FileSize") @Getter @Setter private String fileSize;

@JsonProperty("Duration") @Getter @Setter private String duration;
public boolean hasDuration () { return !empty(duration); }

@JsonProperty("OverallBitRate_Mode") @Getter @Setter private String overallBitRateMode;
@JsonProperty("OverallBitRate") @Getter @Setter private String overallBitRate;
@JsonProperty("FrameRate") @Getter @Setter private String frameRate;
@JsonProperty("FrameRate_Mode") @Getter @Setter private String frameRateMode;
@JsonProperty("FrameRate_Minimum") @Getter @Setter private String frameRateMinimum;
@JsonProperty("FrameRate_Maximum") @Getter @Setter private String frameRateMaximum;
@JsonProperty("FrameCount") @Getter @Setter private String frameCount;
@JsonProperty("ColorSpace") @Getter @Setter private String colorSpace;
@JsonProperty("ChromaSubsampling") @Getter @Setter private String chromaSubsampling;
@JsonProperty("BitDepth") @Getter @Setter private String bitDepth;
@JsonProperty("ScanType") @Getter @Setter private String scanType;
@JsonProperty("StreamSize") @Getter @Setter private String streamSize;
@JsonProperty("StreamSize_Proportion") @Getter @Setter private String streamSizeProperties;
@JsonProperty("HeaderSize") @Getter @Setter private String headerSize;
@JsonProperty("DataSize") @Getter @Setter private String dataSize;
@JsonProperty("FooterSize") @Getter @Setter private String footerSize;
@JsonProperty("IsStreamable") @Getter @Setter private String isStreamable;
@JsonProperty("Title") @Getter @Setter private String title;
@JsonProperty("Movie") @Getter @Setter private String movie;
@JsonProperty("Encoded_Date") @Getter @Setter private String encodedDate;
@JsonProperty("Encoded_Library") @Getter @Setter private String encodedLibrary;
@JsonProperty("Encoded_Library_Name") @Getter @Setter private String encodedLibraryName;
@JsonProperty("Encoded_Library_Version") @Getter @Setter private String encodedLibraryVersion;
@JsonProperty("Encoded_Library_Settings") @Getter @Setter private String encodedLibrarySettings;
@JsonProperty("Tagged_date") @Getter @Setter private String taggedDate;
@JsonProperty("File_Modified_Date") @Getter @Setter private String fileModifiedDate;
@JsonProperty("File_Modified_Date_Local") @Getter @Setter private String fileModifiedDateLocal;
@JsonProperty("Encoded_Application") @Getter @Setter private String encodedApplication;
@JsonProperty("Comment") @Getter @Setter private String comment;
@Getter @Setter private JsonNode extra;

}

+ 11
- 0
src/main/java/jvcl/model/info/JTrackType.java 查看文件

@@ -0,0 +1,11 @@
package jvcl.model.info;

import com.fasterxml.jackson.annotation.JsonCreator;

public enum JTrackType {

general, audio, video, other;

@JsonCreator public static JTrackType fromString(String val) { return valueOf(val.toLowerCase()); }

}

+ 88
- 0
src/main/java/jvcl/op/ConcatOperation.java 查看文件

@@ -0,0 +1,88 @@
package jvcl.op;

import jvcl.model.JAsset;
import jvcl.model.JFileExtension;
import jvcl.model.JOperation;
import jvcl.service.AssetManager;
import jvcl.service.JOperator;
import jvcl.service.Toolbox;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.cobbzilla.util.handlebars.HandlebarsUtil;

import java.io.File;
import java.util.HashMap;
import java.util.Map;

import static jvcl.model.JAsset.json2asset;
import static org.cobbzilla.util.daemon.ZillaRuntime.die;
import static org.cobbzilla.util.daemon.ZillaRuntime.empty;
import static org.cobbzilla.util.io.FileUtil.abs;
import static org.cobbzilla.util.json.JsonUtil.json;
import static org.cobbzilla.util.system.CommandShell.execScript;

@Slf4j
public class ConcatOperation implements JOperator {

public static final String CONCAT_RECODE_TEMPLATE_1
// list inputs
= "{{ffmpeg}} {{#each sources}} -i {{{this.path}}}{{/each}} "
// concat them together
+ "-f concat -safe 0 -c copy {{{output.path}}}";

public static final String CONCAT_RECODE_TEMPLATE_2
// list inputs
= "{{ffmpeg}} {{#each sources}} -i {{{this.path}}}{{/each}} "

// filter: list inputs
+ "-filter_complex \"{{#each sources}}[{{@index}}:v] [{{@index}}:a] {{/each}} "

// filter: concat filter them together
+ "concat=n={{sources.length}}:v=1:a=1 [v] [a]\" "

// output combined result
+ "-map \"[v]\" -map \"[a]\" {{{output.path}}}";

@Override public void operate(JOperation op, Toolbox toolbox, AssetManager assetManager) {

final ConcatConfig config = json(json(op.getPerform()), ConcatConfig.class);

// validate sources
final JAsset[] sources = assetManager.resolve(config.getConcat());
if (empty(sources)) die("operate: no sources");

// create output object
final JAsset output = json2asset(op.getCreates());

// if any format settings are missing, use settings from first source
output.mergeFormat(sources[0].getFormat());

// set the path, check if output asset already exists
final JFileExtension formatType = output.getFormat().getFileExtension();
final File outfile = assetManager.assetPath(op, sources, formatType);
if (outfile.exists()) {
log.info("operate: outfile exists, not re-creating: "+abs(outfile));
return;
}
output.setPath(abs(outfile));

final Map<String, Object> ctx = new HashMap<>();
ctx.put("ffmpeg", toolbox.getFfmpeg());
ctx.put("sources", sources);
ctx.put("output", output);
final String script = HandlebarsUtil.apply(toolbox.getHandlebars(), CONCAT_RECODE_TEMPLATE_1, ctx);

log.debug("operate: running script: "+script);
final String scriptOutput = execScript(script);
log.debug("operate: command output: "+scriptOutput);
assetManager.addOperationAsset(output);
}

@NoArgsConstructor
private static class ConcatConfig {
@Getter @Setter private String[] concat;
}

}

+ 92
- 0
src/main/java/jvcl/op/SplitOperation.java 查看文件

@@ -0,0 +1,92 @@
package jvcl.op;

import jvcl.model.JAsset;
import jvcl.model.JFileExtension;
import jvcl.model.JOperation;
import jvcl.service.AssetManager;
import jvcl.service.JOperator;
import jvcl.service.Toolbox;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.cobbzilla.util.handlebars.HandlebarsUtil;

import java.io.File;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.Map;

import static jvcl.model.JAsset.json2asset;
import static org.cobbzilla.util.daemon.ZillaRuntime.big;
import static org.cobbzilla.util.daemon.ZillaRuntime.empty;
import static org.cobbzilla.util.io.FileUtil.abs;
import static org.cobbzilla.util.json.JsonUtil.json;
import static org.cobbzilla.util.system.CommandShell.execScript;
import static org.cobbzilla.util.time.TimeUtil.parseDuration;

@Slf4j
public class SplitOperation implements JOperator {

public static final String SPLIT_TEMPLATE
= "{{ffmpeg}} -i {{{source.path}}} -ss {{startSeconds}} -t {{endSeconds}} {{{output.path}}}";

@Override public void operate(JOperation op, Toolbox toolbox, AssetManager assetManager) {
final SplitConfig config = json(json(op.getPerform()), SplitConfig.class);

final JAsset source = assetManager.resolve(config.getSplit());

// create output object
final JAsset output = json2asset(op.getCreates());

// if any format settings are missing, use settings from source
output.mergeFormat(source.getFormat());

// get format type
final JFileExtension formatType = output.getFormat().getFileExtension();

final Map<String, Object> ctx = new HashMap<>();
ctx.put("ffmpeg", toolbox.getFfmpeg());
ctx.put("source", source);
final BigDecimal incr = config.getIntervalIncr();
for (BigDecimal i = config.getStartTime();
i.compareTo(config.getEndTime(source)) < 0;
i = i.add(incr)) {

final File outfile = assetManager.assetPath(op, source, formatType, new Object[]{i, incr});
if (outfile.exists()) {
log.info("operate: outfile exists, not re-creating: "+abs(outfile));
return;
}
final JAsset slice = new JAsset(output);
slice.setPath(abs(outfile));

ctx.put("output", slice);
ctx.put("startSeconds", i);
ctx.put("endSeconds", i.add(incr));
final String script = HandlebarsUtil.apply(toolbox.getHandlebars(), SPLIT_TEMPLATE, ctx);
log.debug("operate: running script: "+script);
final String scriptOutput = execScript(script);
log.debug("operate: command output: "+scriptOutput);
output.addAsset(slice);
}
assetManager.addOperationAsset(output);
}

@NoArgsConstructor
private static class SplitConfig {

@Getter @Setter private String split;

@Getter @Setter private String interval;
public BigDecimal getIntervalIncr() { return big(parseDuration(interval)).divide(big(1000), RoundingMode.UNNECESSARY); }

@Getter @Setter private String start;
public BigDecimal getStartTime() { return empty(start) ? BigDecimal.ZERO : big(start); }

@Getter @Setter private String end;
public BigDecimal getEndTime(JAsset source) { return empty(end) ? source.duration() : big(end); }
}

}

+ 13
- 0
src/main/java/jvcl/op/TrimOperation.java 查看文件

@@ -0,0 +1,13 @@
package jvcl.op;

import jvcl.model.JOperation;
import jvcl.service.AssetManager;
import jvcl.service.JOperator;
import jvcl.service.Toolbox;

public class TrimOperation implements JOperator {

@Override public void operate(JOperation op, Toolbox toolbox, AssetManager assetManager) {
}

}

+ 92
- 0
src/main/java/jvcl/service/AssetManager.java 查看文件

@@ -0,0 +1,92 @@
package jvcl.service;

import jvcl.model.JAsset;
import jvcl.model.JFileExtension;
import jvcl.model.JOperation;

import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import static jvcl.model.JAsset.NULL_ASSET;
import static org.cobbzilla.util.daemon.ZillaRuntime.die;
import static org.cobbzilla.util.daemon.ZillaRuntime.empty;

public class AssetManager {

public static final String OUTFILE_PREFIX = AssetManager.class.getSimpleName() + ".output.";

private final Toolbox toolbox;
private final File scratchDir;
private final Map<String, JAsset> assets = new ConcurrentHashMap<>();

public AssetManager(Toolbox toolbox, File scratchDir) {
this.toolbox = toolbox;
this.scratchDir = scratchDir;
}

public File sourcePath(String name) { return new File(scratchDir, OUTFILE_PREFIX + "source." + name); }

public File assetPath(JOperation op, JAsset source, JFileExtension formatType) {
return assetPath(op, source, formatType, null);
}

public File assetPath(JOperation op, JAsset source, JFileExtension formatType, Object[] args) {
return assetPath(op, new JAsset[]{source}, formatType, args);
}

public File assetPath(JOperation op, JAsset[] sources, JFileExtension formatType) {
return assetPath(op, sources, formatType, null);
}

public File assetPath(JOperation op, JAsset[] sources, JFileExtension formatType, Object[] args) {
return new File(scratchDir, OUTFILE_PREFIX
+ op.hash(sources, args)
+ (!empty(args) ? "_" + args[0] + (args.length > 1 ? "_" + args[1] : "") : "")
+ formatType.ext());
}

public Map<String, JAsset> getAssets () {
final Map<String, JAsset> map = new HashMap<>();
for (Map.Entry<String, JAsset> entry : assets.entrySet()) {
map.put(entry.getKey(), entry.getValue());
}
return map;
}

private String checkName(JAsset asset) {
final String name = asset.getName();
if (assets.containsKey(name)) die("defineAsset: name already defined: "+ name);
return name;
}

public void defineAsset(JAsset asset) {
final String name = checkName(asset);
assets.put(name, asset.init(this, toolbox));
}

public void addOperationAsset(JAsset asset) {
if (asset == null || asset == NULL_ASSET) return;
if (asset.hasList()) {
for (JAsset a : asset.getList()) addOperationAsset(a);
} else {
final String name = checkName(asset);
assets.put(name, asset.init(this, toolbox));
}
}

public JAsset[] resolve(String[] assets) {
final JAsset[] resolved = new JAsset[assets.length];
for (int i=0; i<assets.length; i++) {
resolved[i] = resolve(assets[i]);
}
return resolved;
}

public JAsset resolve(String name) {
final JAsset asset = assets.get(name);
return asset == null ? die("resolve("+name+")") : asset;
}

}

+ 9
- 0
src/main/java/jvcl/service/JOperator.java 查看文件

@@ -0,0 +1,9 @@
package jvcl.service;

import jvcl.model.JOperation;

public interface JOperator {

void operate(JOperation op, Toolbox toolbox, AssetManager assetManager);

}

+ 18
- 0
src/main/java/jvcl/service/OperationEngine.java 查看文件

@@ -0,0 +1,18 @@
package jvcl.service;

import jvcl.model.JOperation;

public class OperationEngine {

private final Toolbox toolbox;
private final AssetManager assetManager;

public OperationEngine(Toolbox toolbox, AssetManager assetManager) {
this.toolbox = toolbox;
this.assetManager = assetManager;
}

public void perform(JOperation op) {
op.getOperation().perform(op, toolbox, assetManager);
}
}

+ 67
- 0
src/main/java/jvcl/service/Toolbox.java 查看文件

@@ -0,0 +1,67 @@
package jvcl.service;

import com.github.jknack.handlebars.Handlebars;
import jvcl.model.JAsset;
import jvcl.model.info.JMediaInfo;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.cobbzilla.util.handlebars.HandlebarsUtil;
import org.cobbzilla.util.io.FileUtil;
import org.cobbzilla.util.javascript.StandardJsEngine;

import java.io.File;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import static org.cobbzilla.util.daemon.ZillaRuntime.*;
import static org.cobbzilla.util.io.FileUtil.abs;
import static org.cobbzilla.util.io.FileUtil.replaceExt;
import static org.cobbzilla.util.json.JsonUtil.FULL_MAPPER_ALLOW_UNKNOWN_FIELDS;
import static org.cobbzilla.util.json.JsonUtil.json;
import static org.cobbzilla.util.system.CommandShell.execScript;

@Slf4j
public class Toolbox {

public static final Toolbox DEFAULT_TOOLBOX = new Toolbox();

@Getter(lazy=true) private final Handlebars handlebars = initHandlebars();
private Handlebars initHandlebars() {
final Handlebars hbs = new Handlebars(new HandlebarsUtil(Toolbox.class.getSimpleName()));
HandlebarsUtil.registerUtilityHelpers(hbs);
HandlebarsUtil.registerDateHelpers(hbs);
HandlebarsUtil.registerJavaScriptHelper(hbs, StandardJsEngine::new);
return hbs;
}

@Getter(lazy=true) private final String ffmpeg = initFfmpeg();
private String initFfmpeg() { return loadPath("ffmpeg"); }

@Getter(lazy=true) private final String mediainfo = initMediainfo();
private String initMediainfo() { return loadPath("mediainfo"); }

private static String loadPath(String p) {
try {
final String path = execScript("which "+p);
if (empty(path)) return die("'which "+p+"' returned empty string");
return path.trim();
} catch (Exception e) {
return die("loadPath("+p+"): "+shortError(e));
}
}

private final Map<String, JMediaInfo> infoCache = new ConcurrentHashMap<>();

public JMediaInfo getInfo(JAsset asset) {
final File infoFile = new File(replaceExt(asset.getPath(), ".json"));
final String infoPath = abs(infoFile);
if (!infoFile.exists() || infoFile.length() == 0) {
execScript(getMediainfo() + " --Output=JSON " + abs(asset.getPath())+" > "+infoPath);
}
if (!infoFile.exists() || infoFile.length() == 0) {
return die("getInfo: info file was not created or was empty: "+infoPath);
}
return infoCache.computeIfAbsent(infoPath, p -> json(FileUtil.toStringOrDie(infoFile), JMediaInfo.class, FULL_MAPPER_ALLOW_UNKNOWN_FIELDS));
}

}

+ 28
- 0
src/main/resources/logback.xml 查看文件

@@ -0,0 +1,28 @@
<!-- Copyright (c) 2020 Bubble, Inc. All rights reserved. For personal (non-commercial) use, see license: https://getbubblenow.com/bubble-license/ -->
<configuration>

<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
<!-- reset all previous level configurations of all j.u.l. loggers -->
<resetJUL>true</resetJUL>
</contextListener>

<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<Target>System.err</Target>
<encoder>
<pattern>[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%level] [%thread] %logger{10} [%file:%line] %msg%n</pattern>
</encoder>
</appender>

<logger name="org.cobbzilla" level="WARN" />
<logger name="org.cobbzilla.util.security.bcrypt" level="ERROR" />
<logger name="org.cobbzilla.util.javascript" level="INFO" />
<logger name="org.cobbzilla.util.handlebars" level="INFO" />
<logger name="org.cobbzilla.util.yml" level="ERROR" />
<logger name="org.cobbzilla.util.daemon.ZillaRuntime" level="WARN" />
<logger name="org.cobbzilla.util.io.multi" level="INFO" />

<root level="INFO">
<appender-ref ref="STDERR" />
</root>

</configuration>

+ 21
- 0
src/test/java/javicle/test/SplitTest.java 查看文件

@@ -0,0 +1,21 @@
package javicle.test;

import jvcl.main.Jvcl;
import jvcl.main.JvclOptions;
import lombok.Cleanup;
import org.junit.Test;

import java.io.File;

import static org.cobbzilla.util.io.FileUtil.abs;
import static org.cobbzilla.util.io.StreamUtil.loadResourceAsStream;
import static org.cobbzilla.util.io.StreamUtil.stream2file;

public class SplitTest {

@Test public void testSplit () throws Exception {
@Cleanup("delete") final File specFile = stream2file(loadResourceAsStream("tests/test_split.json"));
Jvcl.main(new String[]{JvclOptions.LONGOPT_SPEC, abs(specFile)});
}

}

+ 2
- 0
src/test/resources/sources/.gitignore 查看文件

@@ -0,0 +1,2 @@
*.mp4
*.json

+ 24
- 0
src/test/resources/tests/test_split.json 查看文件

@@ -0,0 +1,24 @@
{
"assets": [
{
"name": "vid1",
"path": "https://archive.org/download/gov.archives.arc.1257628/gov.archives.arc.1257628_512kb.mp4",
"dest": "src/test/resources/sources/gov.archives.arc.1257628_512kb.mp4"
},
{
"name": "vid2",
"path": "https://archive.org/download/gov.archives.arc.49442/gov.archives.arc.49442_512kb.mp4",
"dest": "src/test/resources/sources/gov.archives.arc.49442_512kb.mp4"
}
],
"operations": [
{
"operation": "split", // name of the operation
"creates": "vid1_split_%", // assets it creates, the '%' will be replaced with a counter
"perform": {
"split": "vid1", // split this source asset
"interval": "10s" // split every ten seconds
}
}
]
}

+ 1
- 0
utils/cobbzilla-utils

@@ -0,0 +1 @@
Subproject commit bcd403f6fdf14985cd99dcbeee257683abd47aaf

Loading…
取消
儲存