// compose.mbt - Video composition using ffmpeg
// NOTE: This is a skeleton implementation showing the workflow.
// Full implementation requires proper JSON parsing for manifest and metadata.
///|
/// Compose final video from slides and audio
///
/// Workflow:
/// 1. Read manifest.json to get slides and their metadata
/// 2. For each slide:
/// - Create video segment from PNG + audio (or static duration)
/// - Use ffmpeg to combine image + audio
/// 3. Concatenate all segments into final video
/// 4. Apply resolution scaling and FPS settings
pub async fn compose_video(
output_dir : String,
output_file : String?,
options : Options,
) -> Bool {
println("Composing video...")
// Check if ffmpeg is installed
if !check_ffmpeg() {
println(" [ERROR] ffmpeg not found")
println(" Please install ffmpeg:")
println(" macOS: brew install ffmpeg")
println(" Ubuntu: sudo apt install ffmpeg")
println(" Windows: https://ffmpeg.org/download.html")
return false
}
// 1. Parse manifest.json
let manifest_path = manifest_path(output_dir)
let manifest_json = @fs.read_file(manifest_path).text()
let manifest : Manifest = (@json.parse(manifest_json) |> @json.from_json) catch {
e => {
println(" [ERROR] Invalid manifest json: \{e}")
return false
}
}
if manifest.slides.length() == 0 {
println(" [ERROR] No slides found in manifest")
return false
}
// 2. Create temp directory for segments
let segments_dir = path_join([output_dir, "segments"])
create_dir_all(segments_dir)
// 3. Generate video segment for each slide and insert videos after slides
let segment_files : Array[String] = []
let mut total_duration = 0.0
let mut segment_counter = 1
for slide in manifest.slides {
// Generate slide segment
let segment_file = path_join([
segments_dir,
"segment-\{pad_slide_id(segment_counter)}.mp4",
])
segment_files.push(segment_file)
segment_counter = segment_counter + 1
let image_path = slide_image_path(output_dir, slide.id)
let audio_path : String? = if slide.has_audio {
Some(slide_audio_path(output_dir, slide.id))
} else {
None
}
// Check if we can skip this segment (caching)
let (can_skip, cached_duration) = should_skip_slide_segment(
output_dir,
slide.id,
segment_file,
image_path,
slide,
)
// Parse resolution once (used for both slides and videos)
let resolution = parse_resolution(options.resolution)
if can_skip {
total_duration = total_duration + cached_duration
println(
" [+] Skipping segment \{segment_counter - 1}/\{manifest.slides.length() + manifest.videos.length()}: slide-\{slide.id} (cached, \{cached_duration}s)",
)
// Don't use continue here - we still need to check for video insertions
} else {
// Get duration from metadata or use default
let duration = if slide.has_audio {
let meta_file = slide_meta_path(output_dir, slide.id)
parse_audio_metadata(meta_file)
.map(fn(meta) { meta.duration })
.unwrap_or_else(fn() {
println(
" [WARN] Could not read duration for slide-\{slide.id}, using default",
)
options.default_duration
})
} else {
options.default_duration
}
total_duration = total_duration + duration
println(
" [*] Generating segment \{segment_counter - 1}/\{manifest.slides.length() + manifest.videos.length()}: slide-\{slide.id} (\{duration}s)",
)
// Generate ffmpeg command for slide
let args = generate_slide_video_command(
image_path,
audio_path,
duration,
segment_file,
resolution,
options.fps,
)
// Run ffmpeg
let (exit_code, _stdout, stderr) = @process.collect_output("ffmpeg", args)
if exit_code != 0 {
println(" [ERROR] Failed to generate segment \{segment_counter - 1}")
println(" ffmpeg error: \{stderr.text()}")
return false
}
// Save segment metadata for caching
let image_hash = hash_file(image_path)
let audio_hash = if slide.has_audio { slide.ssml_hash } else { "" }
let segment_meta_file = slide_segment_meta_path(output_dir, slide.id)
save_segment_metadata(segment_meta_file, duration, image_hash, audio_hash)
}
// Check if there's a video to insert after this slide
let mut video_index = 1 // Track which video number this is (1st, 2nd, etc.)
for video in manifest.videos {
if video.after_slide == slide.id {
let video_segment_file = path_join([
segments_dir,
"segment-\{pad_slide_id(segment_counter)}.mp4",
])
segment_files.push(video_segment_file)
segment_counter = segment_counter + 1
// Check if we can skip this video segment (caching)
let (can_skip_video, cached_video_duration) = should_skip_video_segment(
output_dir,
slide.id,
video_segment_file,
video.video_path,
video,
video_index,
)
if can_skip_video {
total_duration = total_duration + cached_video_duration
println(
" [+] Skipping segment \{segment_counter - 1}/\{manifest.slides.length() + manifest.videos.length()}: video after slide-\{slide.id} (cached, \{cached_video_duration}s)",
)
video_index = video_index + 1 // Increment for next video
continue // Skip to next video
}
// Process video with optional narration
let video_duration = get_video_duration(video.video_path)
let narration_audio : String? = if video.has_audio {
Some(video_audio_path_indexed(output_dir, slide.id, video_index))
} else {
None
}
let narration_meta_file = video_meta_path_indexed(
output_dir,
slide.id,
video_index,
)
let final_duration = if video.has_audio {
parse_audio_metadata(narration_meta_file)
.map(fn(meta) {
if meta.duration > video_duration {
meta.duration
} else {
video_duration
}
})
.unwrap_or(video_duration)
} else {
video_duration
}
total_duration = total_duration + final_duration
println(
" [*] Generating segment \{segment_counter - 1}/\{manifest.slides.length() + manifest.videos.length()}: video after slide-\{slide.id} (\{final_duration}s)",
)
// Generate video segment with optional audio mixing
let video_args = generate_video_segment_command(
video.video_path,
narration_audio,
narration_meta_file,
video_duration,
video_segment_file,
resolution,
options.fps,
)
let (exit_code, _stdout, stderr) = @process.collect_output(
"ffmpeg", video_args,
)
if exit_code != 0 {
println(
" [ERROR] Failed to generate video segment \{segment_counter - 1}",
)
println(" ffmpeg error: \{stderr.text()}")
return false
}
// Save video segment metadata for caching
let video_hash = hash_file(video.video_path)
let video_audio_hash = if video.has_audio {
video.ssml_hash
} else {
""
}
let video_segment_meta_file = video_segment_meta_path(
output_dir,
slide.id,
video_index,
)
save_segment_metadata(
video_segment_meta_file, final_duration, video_hash, video_audio_hash,
)
video_index = video_index + 1 // Increment for next video
}
}
}
// 4. Concatenate all segments
println(" [*] Concatenating \{segment_files.length()} segments...")
let concat_list_file = path_join([segments_dir, "concat.txt"])
let concat_content = generate_concat_list(segment_files)
write_file(concat_list_file, concat_content)
let final_output = output_file.unwrap_or(
path_join([output_dir, "output.mp4"]),
)
let concat_args : Array[String] = [
"-f", "concat", "-safe", "0", "-i", concat_list_file, "-c", "copy", "-y", final_output,
]
let (exit_code, _stdout, stderr) = @process.collect_output(
"ffmpeg", concat_args,
)
if exit_code != 0 {
println(" [ERROR] Failed to concatenate segments")
println(" ffmpeg error: \{stderr.text()}")
return false
}
// 5. Cleanup temporary concat file
if !options.keep_temp {
println(" [*] Cleaning up temporary concat file...")
remove_file(concat_list_file)
}
// Note: Segment files are kept for caching - they will be reused on next run
println(" [+] Video composed successfully!")
println(" Duration: \{total_duration}s")
println(
" Size: ~\{estimate_video_size(total_duration, parse_resolution(options.resolution))}MB",
)
true
}
///|
/// Check if ffmpeg is installed
async fn check_ffmpeg() -> Bool {
let args : Array[String] = ["-version"]
let (exit_code, _stdout, _stderr) = @process.collect_output("ffmpeg", args)
exit_code == 0
}
///|
/// Generate ffmpeg command for creating slide video (skeleton)
fn generate_slide_video_command(
image_path : String,
audio_path : String?,
duration : Double,
output_path : String,
resolution : Resolution,
fps : Int,
) -> Array[String] {
let args : Array[String] = ["-loop", "1", "-i", image_path]
// Add audio if available
if audio_path is Some(path) {
args.push("-i")
args.push(path)
}
// Video encoding settings
args.push("-c:v")
args.push("libx264")
args.push("-tune")
args.push("stillimage")
args.push("-pix_fmt")
args.push("yuv420p")
args.push("-r")
args.push(fps.to_string())
// Resolution scaling
let scale_filter = "scale=\{resolution.width}:\{resolution.height}:force_original_aspect_ratio=decrease,pad=\{resolution.width}:\{resolution.height}:(ow-iw)/2:(oh-ih)/2"
args.push("-vf")
args.push(scale_filter)
// Audio settings or no audio
if audio_path is Some(_) {
args.push("-c:a")
args.push("aac")
args.push("-ar")
args.push("44100")
// Use -t instead of -shortest for precise duration control
args.push("-t")
args.push(duration.to_string())
} else {
args.push("-t")
args.push(duration.to_string())
args.push("-an")
}
// Output
args.push("-y")
args.push(output_path)
args
}
///|
/// Generate ffmpeg command for video segment with optional narration audio
async fn generate_video_segment_command(
video_path : String,
narration_audio : String?,
narration_meta_file : String,
video_duration : Double,
output_path : String,
resolution : Resolution,
fps : Int,
) -> Array[String] {
let args : Array[String] = []
// Check if video has its own audio stream
let video_has_audio = has_audio_stream(video_path)
if narration_audio is Some(audio_path) {
// Case 1 & 2: Video with narration (mix or replace audio)
args.push("-i")
args.push(video_path)
args.push("-i")
args.push(audio_path)
// Video filter: scale + pad + adjust duration to match narration
let scale_filter = "scale=\{resolution.width}:\{resolution.height}:force_original_aspect_ratio=decrease,pad=\{resolution.width}:\{resolution.height}:(ow-iw)/2:(oh-ih)/2"
// Get narration duration from metadata file
let narration_duration = parse_audio_metadata(narration_meta_file)
.map(fn(meta) { meta.duration })
.unwrap_or(video_duration)
if narration_duration > video_duration {
// Freeze last frame to match narration length
let freeze_duration = narration_duration - video_duration
args.push("-filter_complex")
args.push(
"[0:v]\{scale_filter},tpad=stop_mode=clone:stop_duration=\{freeze_duration}[v]",
)
args.push("-map")
args.push("[v]")
} else {
// Trim video to match narration length
args.push("-filter_complex")
args.push(
"[0:v]\{scale_filter},trim=duration=\{narration_duration},setpts=PTS-STARTPTS[v]",
)
args.push("-map")
args.push("[v]")
}
// Audio mixing
if video_has_audio {
// Mix video audio with narration - use shortest to end when narration ends
args.push("-filter_complex")
args.push("[0:a][1:a]amix=inputs=2:duration=shortest[a]")
args.push("-map")
args.push("[a]")
} else {
// Use narration audio only
args.push("-map")
args.push("1:a")
}
// Video and audio encoding
args.push("-c:v")
args.push("libx264")
args.push("-pix_fmt")
args.push("yuv420p")
args.push("-r")
args.push(fps.to_string())
args.push("-c:a")
args.push("aac")
args.push("-ar")
args.push("44100")
// Don't use -shortest - let narration audio control the duration
} else {
// Case 3 & 4: Video without narration (keep or no audio)
args.push("-i")
args.push(video_path)
// Video settings
let scale_filter = "scale=\{resolution.width}:\{resolution.height}:force_original_aspect_ratio=decrease,pad=\{resolution.width}:\{resolution.height}:(ow-iw)/2:(oh-ih)/2"
args.push("-vf")
args.push(scale_filter)
args.push("-c:v")
args.push("libx264")
args.push("-pix_fmt")
args.push("yuv420p")
args.push("-r")
args.push(fps.to_string())
// Audio settings
if video_has_audio {
args.push("-c:a")
args.push("aac")
args.push("-ar")
args.push("44100")
} else {
args.push("-an")
}
}
// Output
args.push("-y")
args.push(output_path)
args
}
///|
/// Generate ffmpeg concat list file content
fn generate_concat_list(segment_files : Array[String]) -> String {
let mut content = ""
for file in segment_files {
// Extract just the filename from the full path
let parts : Array[String] = []
for view in file.split("/") {
parts.push(view.to_owned())
}
let filename = if parts.length() > 0 {
parts[parts.length() - 1]
} else {
file
}
content = content + "file '\{filename}'\n"
}
content
}
///|
/// Parse audio metadata file
async fn parse_audio_metadata(meta_file : String) -> AudioMeta? {
if !@fs.exists(meta_file) {
return None
}
let meta : AudioMeta = (@fs.read_file(meta_file).text()
|> @json.parse
|> @json.from_json) catch {
_ => return None
}
Some(meta)
}
///|
/// Parse segment metadata file
async fn parse_segment_metadata(meta_file : String) -> SegmentMeta? {
if !@fs.exists(meta_file) {
return None
}
let meta : SegmentMeta = (@fs.read_file(meta_file).text()
|> @json.parse
|> @json.from_json) catch {
_ => return None
}
Some(meta)
}
///|
/// Check if a slide segment needs regeneration
/// Returns (should_skip, duration)
async fn should_skip_slide_segment(
output_dir : String,
slide_id : String,
segment_file : String,
image_path : String,
slide : SlideInfo,
) -> (Bool, Double) {
let meta_file = slide_segment_meta_path(output_dir, slide_id)
// Check if segment file exists
if !@fs.exists(segment_file) {
return (false, 0.0)
}
// Parse metadata
guard parse_segment_metadata(meta_file) is Some(meta) else {
return (false, 0.0)
}
// Check if image hash matches
let current_image_hash = hash_file(image_path)
if current_image_hash != meta.content_hash {
return (false, 0.0)
}
// Check if audio hash matches (if has audio)
if slide.has_audio {
if slide.ssml_hash != meta.audio_hash {
return (false, 0.0)
}
}
// All checks passed - can skip
(true, meta.duration)
}
///|
/// Check if a video segment needs regeneration
/// Returns (should_skip, duration)
async fn should_skip_video_segment(
output_dir : String,
slide_id : String,
segment_file : String,
video_path : String,
video : VideoSegment,
video_index : Int,
) -> (Bool, Double) {
let meta_file = video_segment_meta_path(output_dir, slide_id, video_index)
// Check if segment file exists
if !@fs.exists(segment_file) {
return (false, 0.0)
}
// Parse metadata
guard parse_segment_metadata(meta_file) is Some(meta) else {
return (false, 0.0)
}
// Check if video hash matches
let current_video_hash = hash_file(video_path)
if current_video_hash != meta.content_hash {
return (false, 0.0)
}
// Check if audio hash matches (if has audio)
if video.has_audio {
if video.ssml_hash != meta.audio_hash {
return (false, 0.0)
}
}
// All checks passed - can skip
(true, meta.duration)
}
///|
/// Save segment metadata after generation
async fn save_segment_metadata(
meta_file : String,
duration : Double,
content_hash : String,
audio_hash : String,
) -> Unit {
let meta : SegmentMeta = { duration, content_hash, audio_hash, }
write_file(meta_file, Json::stringify(meta.to_json()))
}
///|
extend SegmentMeta with ToJson::{to_json}