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