@@ -1,5 +1,6 @@ | |||||
package jvcl.model; | package jvcl.model; | ||||
import com.fasterxml.jackson.annotation.JsonIgnore; | |||||
import com.fasterxml.jackson.databind.JsonNode; | import com.fasterxml.jackson.databind.JsonNode; | ||||
import jvcl.model.info.JMediaInfo; | import jvcl.model.info.JMediaInfo; | ||||
import jvcl.service.AssetManager; | import jvcl.service.AssetManager; | ||||
@@ -16,7 +17,11 @@ import java.io.FileOutputStream; | |||||
import java.io.InputStream; | import java.io.InputStream; | ||||
import java.io.OutputStream; | import java.io.OutputStream; | ||||
import java.math.BigDecimal; | import java.math.BigDecimal; | ||||
import java.util.*; | |||||
import java.util.regex.Pattern; | |||||
import java.util.stream.Collectors; | |||||
import static java.util.Comparator.comparing; | |||||
import static org.cobbzilla.util.daemon.ZillaRuntime.*; | import static org.cobbzilla.util.daemon.ZillaRuntime.*; | ||||
import static org.cobbzilla.util.http.HttpSchemes.isHttpOrHttps; | import static org.cobbzilla.util.http.HttpSchemes.isHttpOrHttps; | ||||
import static org.cobbzilla.util.io.FileUtil.abs; | import static org.cobbzilla.util.io.FileUtil.abs; | ||||
@@ -24,6 +29,7 @@ import static org.cobbzilla.util.io.FileUtil.mkdirOrDie; | |||||
import static org.cobbzilla.util.io.StreamUtil.loadResourceAsStream; | import static org.cobbzilla.util.io.StreamUtil.loadResourceAsStream; | ||||
import static org.cobbzilla.util.json.JsonUtil.json; | import static org.cobbzilla.util.json.JsonUtil.json; | ||||
import static org.cobbzilla.util.reflect.ReflectionUtil.copy; | import static org.cobbzilla.util.reflect.ReflectionUtil.copy; | ||||
import static org.cobbzilla.util.system.CommandShell.execScript; | |||||
@NoArgsConstructor @AllArgsConstructor @Accessors(chain=true) @Slf4j | @NoArgsConstructor @AllArgsConstructor @Accessors(chain=true) @Slf4j | ||||
public class JAsset { | public class JAsset { | ||||
@@ -41,6 +47,25 @@ public class JAsset { | |||||
// an asset can specify where its file should live | // an asset can specify where its file should live | ||||
// if the file already exists, it is used and not overwritten | // if the file already exists, it is used and not overwritten | ||||
@Getter @Setter private String dest; | @Getter @Setter private String dest; | ||||
public static List<JAsset> flattenAssetList(JAsset[] assets) { | |||||
final List<JAsset> list = new ArrayList<>(); | |||||
return _flatten(assets, list); | |||||
} | |||||
private static List<JAsset> _flatten(JAsset[] assets, final List<JAsset> list) { | |||||
if (assets != null) { | |||||
for (final JAsset a : assets) { | |||||
if (a.hasList()) { | |||||
_flatten(a.getList(), list); | |||||
} else { | |||||
list.add(a); | |||||
} | |||||
} | |||||
} | |||||
return list; | |||||
} | |||||
public boolean hasDest() { return !empty(dest); } | public boolean hasDest() { return !empty(dest); } | ||||
public boolean destExists() { return new File(dest).exists(); } | public boolean destExists() { return new File(dest).exists(); } | ||||
public boolean destIsDirectory() { return new File(dest).isDirectory(); } | public boolean destIsDirectory() { return new File(dest).isDirectory(); } | ||||
@@ -54,7 +79,9 @@ public class JAsset { | |||||
@Getter @Setter private JAsset[] list; | @Getter @Setter private JAsset[] list; | ||||
public boolean hasList () { return list != null; } | public boolean hasList () { return list != null; } | ||||
public boolean hasListAssets () { return !empty(getList()); } | |||||
public void addAsset(JAsset slice) { list = ArrayUtil.append(list, slice); } | public void addAsset(JAsset slice) { list = ArrayUtil.append(list, slice); } | ||||
@JsonIgnore public Integer getLength () { return hasList() ? list.length : null; } | |||||
@Getter @Setter private JFormat format; | @Getter @Setter private JFormat format; | ||||
public boolean hasFormat() { return format != null; } | public boolean hasFormat() { return format != null; } | ||||
@@ -67,6 +94,19 @@ public class JAsset { | |||||
} | } | ||||
public boolean hasInfo() { return info != null; } | public boolean hasInfo() { return info != null; } | ||||
@Override public boolean equals(Object o) { | |||||
if (this == o) return true; | |||||
if (o == null || getClass() != o.getClass()) return false; | |||||
final JAsset a = (JAsset) o; | |||||
return Objects.equals(path, a.path) && Arrays.equals(list, a.list); | |||||
} | |||||
@Override public int hashCode() { | |||||
int result = Objects.hash(path); | |||||
result = 31 * result + Arrays.hashCode(list); | |||||
return result; | |||||
} | |||||
public static JAsset json2asset(JsonNode node) { | public static JAsset json2asset(JsonNode node) { | ||||
if (node.isObject()) return json(node, JAsset.class); | if (node.isObject()) return json(node, JAsset.class); | ||||
if (node.isTextual()) return new JAsset().setName(node.textValue()); | if (node.isTextual()) return new JAsset().setName(node.textValue()); | ||||
@@ -88,8 +128,13 @@ public class JAsset { | |||||
public BigDecimal duration() { return getInfo().duration(); } | public BigDecimal duration() { return getInfo().duration(); } | ||||
public JAsset init(AssetManager assetManager, Toolbox toolbox) { | public JAsset init(AssetManager assetManager, Toolbox toolbox) { | ||||
initPath(assetManager); | |||||
setInfo(toolbox.getInfo(this)); | |||||
final JAsset asset = initPath(assetManager); | |||||
if (!asset.hasListAssets()) { | |||||
setInfo(toolbox.getInfo(this)); | |||||
} else { | |||||
setInfo(toolbox.getInfo(list[0])); | |||||
for (JAsset a : asset.getList()) a.setInfo(getInfo()); | |||||
} | |||||
return this; | return this; | ||||
} | } | ||||
@@ -132,9 +177,32 @@ public class JAsset { | |||||
} else { | } else { | ||||
// must be a file | // 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); | |||||
// does it contain a wildcard? | |||||
if (path.contains("*")) { | |||||
// find matching files | |||||
final int starPos = path.indexOf("*"); | |||||
final int lastSlash = path.substring(0, starPos).lastIndexOf("/"); | |||||
final String[] parts = path.split("\\*"); | |||||
final StringBuilder b = new StringBuilder(); | |||||
Arrays.stream(parts).forEach(p -> { | |||||
if (b.length() > 0) b.append(".+"); | |||||
b.append(Pattern.quote(p)); | |||||
}); | |||||
final Pattern regex = Pattern.compile(b.toString()); | |||||
final File dir = new File(path.substring(0, lastSlash)); | |||||
final String filesInDir = execScript("find " + dir + " -type f"); | |||||
final Set<String> matches = Arrays.stream(filesInDir.split("\n")) | |||||
.filter(f -> regex.matcher(f).matches()) | |||||
.collect(Collectors.toCollection(() -> new TreeSet<>(comparing(String::toString)))); | |||||
for (String f : matches) { | |||||
addAsset(new JAsset(this).setPath(f)); | |||||
} | |||||
} else { | |||||
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; | return this; | ||||
} | } | ||||
@@ -19,7 +19,7 @@ public class JOperation { | |||||
public String hash(JAsset[] sources) { return hash(sources, null); } | public String hash(JAsset[] sources) { return hash(sources, null); } | ||||
public String hash(JAsset[] sources, Object[] args) { | public String hash(JAsset[] sources, Object[] args) { | ||||
return hashOf(getOperation(), json(creates), json(perform), json(sources), args); | |||||
return hashOf(getOperation(), json(creates), json(perform), sources, args); | |||||
} | } | ||||
} | } |
@@ -14,8 +14,10 @@ import org.cobbzilla.util.handlebars.HandlebarsUtil; | |||||
import java.io.File; | import java.io.File; | ||||
import java.util.HashMap; | import java.util.HashMap; | ||||
import java.util.List; | |||||
import java.util.Map; | import java.util.Map; | ||||
import static jvcl.model.JAsset.flattenAssetList; | |||||
import static jvcl.model.JAsset.json2asset; | import static jvcl.model.JAsset.json2asset; | ||||
import static org.cobbzilla.util.daemon.ZillaRuntime.die; | import static org.cobbzilla.util.daemon.ZillaRuntime.die; | ||||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | ||||
@@ -26,13 +28,13 @@ import static org.cobbzilla.util.system.CommandShell.execScript; | |||||
@Slf4j | @Slf4j | ||||
public class ConcatOperation implements JOperator { | 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_OLD | |||||
// concat inputs | |||||
= "{{ffmpeg}} -f concat{{#each sources}} -i {{{this.path}}}{{/each}} " | |||||
// safely with copy codec | |||||
+ "-safe 0 -c copy {{{output.path}}}"; | |||||
public static final String CONCAT_RECODE_TEMPLATE_2 | |||||
public static final String CONCAT_RECODE_TEMPLATE_1 | |||||
// list inputs | // list inputs | ||||
= "{{ffmpeg}} {{#each sources}} -i {{{this.path}}}{{/each}} " | = "{{ffmpeg}} {{#each sources}} -i {{{this.path}}}{{/each}} " | ||||
@@ -50,22 +52,30 @@ public class ConcatOperation implements JOperator { | |||||
final ConcatConfig config = json(json(op.getPerform()), ConcatConfig.class); | final ConcatConfig config = json(json(op.getPerform()), ConcatConfig.class); | ||||
// validate sources | // validate sources | ||||
final JAsset[] sources = assetManager.resolve(config.getConcat()); | |||||
final List<JAsset> sources = flattenAssetList(assetManager.resolve(config.getConcat())); | |||||
if (empty(sources)) die("operate: no sources"); | if (empty(sources)) die("operate: no sources"); | ||||
// create output object | // create output object | ||||
final JAsset output = json2asset(op.getCreates()); | final JAsset output = json2asset(op.getCreates()); | ||||
if (output.hasDest() && output.destExists()) { | |||||
log.info("operate: dest exists, not re-creating: "+output.getDest()); | |||||
return; | |||||
} | |||||
// if any format settings are missing, use settings from first source | // if any format settings are missing, use settings from first source | ||||
output.mergeFormat(sources[0].getFormat()); | |||||
output.mergeFormat(sources.get(0).getFormat()); | |||||
// set the path, check if output asset already exists | // set the path, check if output asset already exists | ||||
final JFileExtension formatType = output.getFormat().getFileExtension(); | final JFileExtension formatType = output.getFormat().getFileExtension(); | ||||
final File outfile = assetManager.assetPath(op, sources, formatType); | |||||
final File outfile = output.hasDest() | |||||
? new File(output.getDest()) | |||||
: assetManager.assetPath(op, sources, formatType); | |||||
if (outfile.exists()) { | if (outfile.exists()) { | ||||
log.info("operate: outfile exists, not re-creating: "+abs(outfile)); | log.info("operate: outfile exists, not re-creating: "+abs(outfile)); | ||||
return; | return; | ||||
} | } | ||||
if (!outfile.getParentFile().canWrite()) die("operate: cannot write file (parent directory not writeable): "+abs(outfile)); | |||||
output.setPath(abs(outfile)); | output.setPath(abs(outfile)); | ||||
final Map<String, Object> ctx = new HashMap<>(); | final Map<String, Object> ctx = new HashMap<>(); | ||||
@@ -30,7 +30,7 @@ import static org.cobbzilla.util.system.CommandShell.execScript; | |||||
public class SplitOperation implements JOperator { | public class SplitOperation implements JOperator { | ||||
public static final String SPLIT_TEMPLATE | public static final String SPLIT_TEMPLATE | ||||
= "{{ffmpeg}} -i {{{source.path}}} -ss {{startSeconds}} -t {{endSeconds}} {{{output.path}}}"; | |||||
= "{{ffmpeg}} -i {{{source.path}}} -ss {{startSeconds}} -t {{interval}} {{{output.path}}}"; | |||||
@Override public void operate(JOperation op, Toolbox toolbox, AssetManager assetManager) { | @Override public void operate(JOperation op, Toolbox toolbox, AssetManager assetManager) { | ||||
final SplitConfig config = json(json(op.getPerform()), SplitConfig.class); | final SplitConfig config = json(json(op.getPerform()), SplitConfig.class); | ||||
@@ -83,7 +83,7 @@ public class SplitOperation implements JOperator { | |||||
ctx.put("output", slice); | ctx.put("output", slice); | ||||
ctx.put("startSeconds", i); | ctx.put("startSeconds", i); | ||||
ctx.put("endSeconds", i.add(incr)); | |||||
ctx.put("interval", incr); | |||||
final String script = HandlebarsUtil.apply(toolbox.getHandlebars(), SPLIT_TEMPLATE, ctx); | final String script = HandlebarsUtil.apply(toolbox.getHandlebars(), SPLIT_TEMPLATE, ctx); | ||||
log.debug("operate: running script: "+script); | log.debug("operate: running script: "+script); | ||||
@@ -3,19 +3,24 @@ package jvcl.service; | |||||
import jvcl.model.JAsset; | import jvcl.model.JAsset; | ||||
import jvcl.model.JFileExtension; | import jvcl.model.JFileExtension; | ||||
import jvcl.model.JOperation; | import jvcl.model.JOperation; | ||||
import org.cobbzilla.util.handlebars.HandlebarsUtil; | |||||
import java.io.File; | import java.io.File; | ||||
import java.util.HashMap; | import java.util.HashMap; | ||||
import java.util.List; | |||||
import java.util.Map; | import java.util.Map; | ||||
import java.util.concurrent.ConcurrentHashMap; | import java.util.concurrent.ConcurrentHashMap; | ||||
import static java.lang.Integer.parseInt; | |||||
import static jvcl.model.JAsset.NULL_ASSET; | import static jvcl.model.JAsset.NULL_ASSET; | ||||
import static org.cobbzilla.util.daemon.ZillaRuntime.die; | import static org.cobbzilla.util.daemon.ZillaRuntime.die; | ||||
import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | import static org.cobbzilla.util.daemon.ZillaRuntime.empty; | ||||
import static org.cobbzilla.util.string.StringUtil.isOnlyDigits; | |||||
public class AssetManager { | public class AssetManager { | ||||
public static final String OUTFILE_PREFIX = AssetManager.class.getSimpleName() + ".output."; | public static final String OUTFILE_PREFIX = AssetManager.class.getSimpleName() + ".output."; | ||||
public static final String RANGE_SEP = ".."; | |||||
private final Toolbox toolbox; | private final Toolbox toolbox; | ||||
private final File scratchDir; | private final File scratchDir; | ||||
@@ -40,6 +45,10 @@ public class AssetManager { | |||||
return assetPath(op, sources, formatType, null); | return assetPath(op, sources, formatType, null); | ||||
} | } | ||||
public File assetPath(JOperation op, List<JAsset> sources, JFileExtension formatType) { | |||||
return assetPath(op, sources.toArray(JAsset[]::new), formatType, null); | |||||
} | |||||
public File assetPath(JOperation op, JAsset[] sources, JFileExtension formatType, Object[] args) { | public File assetPath(JOperation op, JAsset[] sources, JFileExtension formatType, Object[] args) { | ||||
return new File(scratchDir, OUTFILE_PREFIX | return new File(scratchDir, OUTFILE_PREFIX | ||||
+ op.hash(sources, args) | + op.hash(sources, args) | ||||
@@ -93,8 +102,72 @@ public class AssetManager { | |||||
} | } | ||||
public JAsset resolve(String name) { | public JAsset resolve(String name) { | ||||
final JAsset asset = assets.get(name); | |||||
final JAsset asset; | |||||
if (name.contains("[") && name.contains("]")) { | |||||
final int openPos = name.indexOf("["); | |||||
final int closePos = name.indexOf("]"); | |||||
final String assetName = name.substring(0, openPos); | |||||
asset = assets.get(assetName); | |||||
if (asset == null) return die("resolve("+name+"): asset not found: "+assetName); | |||||
if (!asset.hasList()) return die("resolve("+name+"): "+assetName+" is not a list asset"); | |||||
final JAsset[] list = asset.getList(); | |||||
final String indexExpr = name.substring(openPos+1, closePos); | |||||
if (indexExpr.contains(RANGE_SEP)) { | |||||
final int dotPos = indexExpr.indexOf(RANGE_SEP); | |||||
final String from = indexExpr.substring(0, dotPos); | |||||
final String to = indexExpr.substring(dotPos+RANGE_SEP.length()); | |||||
final int fromIndex = parseIndexExpression(from, list, JIndexType.from); | |||||
final int toIndex = parseIndexExpression(to, list, JIndexType.to); | |||||
if (toIndex < fromIndex) return die("parseIndexExpression("+indexExpr+"): 'to' index ("+toIndex+") < 'from' index ("+fromIndex+")"); | |||||
final int len = toIndex - fromIndex; | |||||
final JAsset[] subList = new JAsset[len]; | |||||
System.arraycopy(list, fromIndex, subList, 0, len); | |||||
return new JAsset(asset).setList(subList); | |||||
} else { | |||||
// single element | |||||
final int index = parseIndexExpression(indexExpr, list, JIndexType.single); | |||||
if (index < 0 || index >= list.length) return die("parseIndexExpression("+indexExpr+"): index out of range: "+index); | |||||
return list[index]; | |||||
} | |||||
} else { | |||||
asset = assets.get(name); | |||||
} | |||||
return asset == null ? die("resolve("+name+")") : asset; | return asset == null ? die("resolve("+name+")") : asset; | ||||
} | } | ||||
private int parseIndexExpression(String indexExpr, JAsset[] list, JIndexType type) { | |||||
if (empty(indexExpr)) { | |||||
switch (type) { | |||||
case from: return 0; | |||||
case to: return list.length; | |||||
case single: return die("parseIndexExpression(): no expression provided!"); | |||||
default: return die("parseIndexExpression(): invalid type: "+type); | |||||
} | |||||
} | |||||
final int index; | |||||
if (isOnlyDigits(indexExpr)) { | |||||
try { | |||||
index = parseInt(indexExpr); | |||||
} catch (Exception e) { | |||||
return die("parseIndexExpression("+indexExpr+"): not an integer: "+indexExpr); | |||||
} | |||||
} else { | |||||
// not all digits, evaluate as handlebars expression | |||||
final Map<String, Object> ctx = new HashMap<>(assets); | |||||
final String indexString = HandlebarsUtil.apply(toolbox.getHandlebars(), "{{{" + indexExpr + "}}}", ctx); | |||||
if (empty(indexString)) return die("parseIndexExpression("+indexExpr+"): index expression resolved to empty string: "+indexExpr); | |||||
try { | |||||
index = parseInt(indexString); | |||||
} catch (Exception e) { | |||||
return die("parseIndexExpression("+indexExpr+"): index expression did not evaluate to an integer: "+indexString); | |||||
} | |||||
} | |||||
if (index < 0 || index > list.length) return die("parseIndexExpression("+indexExpr+"): '"+type+"' index out of range: "+index); | |||||
return index; | |||||
} | |||||
} | } |
@@ -0,0 +1,11 @@ | |||||
package jvcl.service; | |||||
import com.fasterxml.jackson.annotation.JsonCreator; | |||||
public enum JIndexType { | |||||
single, from, to; | |||||
@JsonCreator public static JIndexType fromString (String v) { return valueOf(v.toLowerCase()); } | |||||
} |
@@ -70,7 +70,12 @@ public class Toolbox { | |||||
if (!infoFile.exists() || infoFile.length() == 0) { | if (!infoFile.exists() || infoFile.length() == 0) { | ||||
return die("getInfo: info file was not created or was empty: "+infoPath); | 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)); | |||||
return infoCache.computeIfAbsent(infoPath, p -> { | |||||
try { | |||||
return json(FileUtil.toStringOrDie(infoFile), JMediaInfo.class, FULL_MAPPER_ALLOW_UNKNOWN_FIELDS); | |||||
} catch (Exception e) { | |||||
return die("getInfo: "+shortError(e), e); | |||||
} | |||||
}); | |||||
} | } | ||||
} | } |
@@ -1,21 +1,25 @@ | |||||
package javicle.test; | package javicle.test; | ||||
import jvcl.main.Jvcl; | import jvcl.main.Jvcl; | ||||
import jvcl.main.JvclOptions; | |||||
import lombok.Cleanup; | import lombok.Cleanup; | ||||
import org.junit.Test; | import org.junit.Test; | ||||
import java.io.File; | import java.io.File; | ||||
import static jvcl.main.JvclOptions.LONGOPT_SPEC; | |||||
import static org.cobbzilla.util.io.FileUtil.abs; | import static org.cobbzilla.util.io.FileUtil.abs; | ||||
import static org.cobbzilla.util.io.StreamUtil.loadResourceAsStream; | import static org.cobbzilla.util.io.StreamUtil.loadResourceAsStream; | ||||
import static org.cobbzilla.util.io.StreamUtil.stream2file; | import static org.cobbzilla.util.io.StreamUtil.stream2file; | ||||
public class BasicTest { | public class BasicTest { | ||||
@Test public void testSplitAndConcat () throws Exception { | |||||
@Cleanup("delete") final File specFile = stream2file(loadResourceAsStream("tests/test_split.json")); | |||||
Jvcl.main(new String[]{JvclOptions.LONGOPT_SPEC, abs(specFile)}); | |||||
@Test public void testSplit() { runSpec("tests/test_split.json"); } | |||||
@Test public void testConcat() { runSpec("tests/test_concat.json"); } | |||||
private void runSpec(String specPath) { | |||||
@Cleanup("delete") final File specFile = stream2file(loadResourceAsStream(specPath)); | |||||
Jvcl.main(new String[]{LONGOPT_SPEC, abs(specFile)}); | |||||
} | } | ||||
} | } |
@@ -1,13 +1,16 @@ | |||||
{ | { | ||||
"assets": [ | "assets": [ | ||||
{ "name": "vid1_splits" } | |||||
{ "name": "vid1_splits", "path": "src/test/resources/outputs/vid1_splits_*.mp4" } | |||||
], | ], | ||||
"operations": [ | "operations": [ | ||||
{ | { | ||||
"operation": "concat", | "operation": "concat", | ||||
"creates": "combined_vid", | |||||
"creates": { | |||||
"name": "combined_vid", | |||||
"dest": "src/test/resources/outputs/combined.mp4" | |||||
}, | |||||
"perform": { | "perform": { | ||||
"concat": ["vid1_splits[1..{{vid1_splits.length}}"] | |||||
"concat": ["vid1_splits[1..]"] | |||||
} | } | ||||
} | } | ||||
] | ] | ||||
@@ -1 +1 @@ | |||||
Subproject commit bcd403f6fdf14985cd99dcbeee257683abd47aaf | |||||
Subproject commit 9f3bc574b0e9fc99b41b381116ff37a9b8844eaf |