diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5f724cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/.log/
diff --git a/audio_props.mjs b/audio_props.mjs
new file mode 100644
index 0000000..3ddfbc9
--- /dev/null
+++ b/audio_props.mjs
@@ -0,0 +1,13 @@
+const audio_props = {
+ "stream.handler_name.markup": "SoundHandler",
+ "stream.type": "audio",
+ "codec.sample_fmt": (metadata) => {
+ const type = metadata.data.Format_Settings_Sign == "Signed" ? "s" : "u";
+ const bit_depth = metadata.data.BitDepth;
+ return `${type}${bit_depth}`;
+ },
+ "codec.channels": (metadata) => metadata.data.Channels,
+ "codec.sample_rate": (metadata) => metadata.data.SamplingRate,
+};
+
+export default audio_props;
diff --git a/kdenlive.mjs b/kdenlive.mjs
new file mode 100755
index 0000000..3309abc
--- /dev/null
+++ b/kdenlive.mjs
@@ -0,0 +1,104 @@
+#!/usr/bin/zx --quiet
+
+import { formatDuration } from "./util.mjs";
+import Producer from "./producer.mjs";
+
+export default async function kdenliveProject(
+ source_files,
+ project_settings, // const project_settings = { fps: 30 };
+ clips
+) {
+ const producers = await Promise.all(source_files.map(Producer.fromFile));
+ return `
+
+
+${(
+ await Promise.all(
+ producers.map((producer) => producer.toXML(project_settings))
+ )
+).join("\n")}
+
+ 2
+ 2
+ 1
+ 0
+ 1633881496938
+ [
+]
+
+ 21.08.1
+ 1.02
+
+
+ 1
+${producers
+ .map(
+ (producer, index) =>
+ ``
+ )
+ .join("\n")}
+
+
+ 2147483647
+ continue
+ black
+ 1
+ color
+ rgb24a
+ 0
+
+
+ 1
+
+
+ 1
+
+
+ 1
+ 1
+
+
+
+
+ 1
+
+
+ 1
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+const project_content = await kdenliveProject(
+ ["/home/kuba/Videos/5min.mp4", "/home/kuba/Videos/5min.wav"],
+ {
+ fps: 30,
+ }
+);
+await $`echo ${project_content} > project-generated.kdenlive`;
diff --git a/producer.mjs b/producer.mjs
new file mode 100644
index 0000000..f4368d2
--- /dev/null
+++ b/producer.mjs
@@ -0,0 +1,112 @@
+import {
+ getStream,
+ indexOf,
+ renderAllProps,
+ getStreamIndex,
+ formatDuration,
+} from "./util.mjs";
+import { makeIDGen } from "./util.mjs";
+import video_props from "./video_props.mjs";
+import audio_props from "./audio_props.mjs";
+
+const makeId = makeIDGen(1);
+const producerIndexGen = makeIDGen(0);
+
+export default class Producer {
+ constructor(metadata) {
+ this.metadata = metadata;
+ this.index = producerIndexGen.next().value;
+ }
+
+ static prop_types = {
+ length: (metadata, project_settings) => {
+ const duration = getStream(metadata.track, "General").Duration;
+ return `${duration * project_settings.fps}`;
+ },
+ eof: "pause",
+ resource: (metadata) => metadata["@ref"],
+ audio_index: (metadata) => getStreamIndex(metadata.track, "Audio"),
+ video_index: (metadata) => getStreamIndex(metadata.track, "Video"),
+ mute_on_pause: "0",
+ mlt_service: "avformat-novalidate",
+ seekable: "1",
+ aspect_ratio: "1",
+ "kdenlive:clipname": "",
+ "kdenlive:folderid": "-1",
+ "kdenlive:audio_max0": "208",
+ "kdenlive:id": () => makeId.next().value,
+ "kdenlive:file_size": async (metadata) =>
+ (
+ await $`du --bytes ${metadata["@ref"]} | awk '{print $1}'`
+ ).stdout.replace("\n", ""),
+ "kdenlive:file_hash": async (metadata) =>
+ (
+ await $`head -c 1000000 ${metadata["@ref"]} && tail -c 1000000 ${metadata["@ref"]}`.pipe(
+ $`md5sum | awk '{print $1}'`
+ )
+ ).stdout.replace("\n", ""),
+ "meta.media.nb_streams": (metadata) => metadata.track.length - 1,
+ $$$video: async (metadata, project_settings) => {
+ const video_index = indexOf(metadata.track, (e) => e["@type"] == "Video");
+ if (video_index == -1) {
+ return null;
+ }
+ return {
+ $replace: await renderAllProps(
+ video_props,
+ {
+ index: video_index,
+ path: metadata["@ref"],
+ data: metadata.track[video_index],
+ },
+ project_settings,
+ `meta.media.${getStreamIndex(metadata.track, "Video")}.`
+ ),
+ };
+ },
+ $$$audio: async (metadata, project_settings) => {
+ const audio_index = indexOf(metadata.track, (e) => e["@type"] == "Audio");
+ if (audio_index == -1) {
+ return null;
+ }
+ return {
+ $replace: await renderAllProps(
+ audio_props,
+ {
+ index: audio_index,
+ path: metadata["@ref"],
+ data: metadata.track[audio_index],
+ },
+ project_settings,
+ `meta.media.${getStreamIndex(metadata.track, "Audio")}.`
+ ),
+ };
+ },
+ };
+
+ static async fromFile(file_path) {
+ const metadata = JSON.parse(
+ (await $`mediainfo --Output=JSON ${file_path}`).stdout
+ ).media;
+ return new Producer(metadata);
+ }
+
+ getDuration() {
+ return formatDuration(
+ parseFloat(getStream(this.metadata.track, "General").Duration)
+ );
+ }
+
+ async toXML(project_settings) {
+ return `
+ ${await renderAllProps(
+ Producer.prop_types,
+ this.metadata,
+ project_settings
+ )}
+
+ `;
+ }
+}
diff --git a/util.mjs b/util.mjs
new file mode 100644
index 0000000..ea3058d
--- /dev/null
+++ b/util.mjs
@@ -0,0 +1,84 @@
+export function indexOf(array, predicate) {
+ for (let i in array) {
+ if (predicate(array[i])) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+export async function renderProperty(
+ name,
+ fn,
+ metadata,
+ project_settings,
+ prefix = ""
+) {
+ let value;
+ if (typeof fn === "string") {
+ value = fn;
+ } else {
+ value = await fn(metadata, project_settings);
+ }
+ console.log(name, value);
+ if (value === null) {
+ return "";
+ } else if (value && value.$replace) {
+ return value.$replace;
+ } else {
+ return `${value}`;
+ }
+}
+
+export async function renderAllProps(
+ props,
+ metadata,
+ project_settings,
+ prefix = ""
+) {
+ return (
+ await Promise.all(
+ Object.entries(props).map(([name, fn]) =>
+ renderProperty(name, fn, metadata, project_settings, prefix)
+ )
+ )
+ ).join("\n");
+}
+
+export function getStream(tracks, type) {
+ return tracks.find((track) => track["@type"] == type);
+}
+
+const HOUR = 60 * 60;
+const MINUTE = 60;
+
+export function formatDuration(float_s) {
+ const hours = Math.floor(float_s / HOUR);
+ float_s = float_s - hours * HOUR;
+ const minutes = Math.floor(float_s / MINUTE);
+ const seconds = float_s - minutes * MINUTE;
+ return `${twoDigits(hours)}:${twoDigits(minutes)}:${twoDigits(seconds, 5)}`;
+}
+
+export function twoDigits(number, decimal = 0) {
+ let [int, dec] = number.toFixed(decimal).split(".");
+ dec = dec || "";
+ if (dec == "") {
+ return int.padStart(2, "0");
+ } else {
+ return int.padStart(2, "0") + "." + dec;
+ }
+}
+
+export function getStreamIndex(trackarray, type) {
+ const array_index = indexOf(trackarray, (e) => e["@type"] == type);
+ return array_index == -1 ? -1 : trackarray[array_index].StreamOrder || 0;
+}
+
+export function* makeIDGen(first = 1) {
+ let i = first;
+ while (true) {
+ yield i;
+ i++;
+ }
+}
diff --git a/video_props.mjs b/video_props.mjs
new file mode 100644
index 0000000..50f2a1c
--- /dev/null
+++ b/video_props.mjs
@@ -0,0 +1,12 @@
+const video_props = {
+ "stream.type": "video",
+ "stream.frame_rate": (metadata) => metadata.data.FrameRate,
+ "stream.sample_aspect_ratio": "1",
+ "codec.width": (metadata) => metadata.data.Width,
+ "codec.height": (metadata) => metadata.data.Height,
+ "codec.rotate": "0",
+ "codec.frame_rate": (metadata) => metadata.data.FrameRate,
+ "stream.handler_name.markup": "VideoHandle",
+};
+
+export default video_props
\ No newline at end of file