// utils.mbt - Utility functions for mdcourse
///|
/// Pad a string to the left with a character
fn pad_start(s : String, length : Int, pad_char : Char) -> String {
let current_len = s.length()
if current_len >= length {
return s
}
let pad_count = length - current_len
let mut result = ""
for _i = 0; _i < pad_count; _i = _i + 1 {
result = result + pad_char.to_string()
}
result + s
}
///|
/// Pad a number to 3 digits (for slide IDs)
fn pad_slide_id(num : Int) -> String {
pad_start(num.to_string(), 3, '0')
}
///|
/// Simple path join (Unix-style)
fn path_join(parts : Array[String]) -> String {
let mut result = ""
for i, part in parts {
if i > 0 && !result.is_empty() {
result = result + "/"
}
result = result + part
}
result
}
///|
/// Read 32-bit little-endian integer from Bytes
fn read_u32_le(bytes : Bytes, offset : Int) -> Int {
let b0 = bytes[offset].to_int()
let b1 = bytes[offset + 1].to_int()
let b2 = bytes[offset + 2].to_int()
let b3 = bytes[offset + 3].to_int()
b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
}
///|
/// Calculate WAV audio duration from WAV file bytes
/// WAV header structure:
/// - Bytes 24-27: Sample rate (little-endian) - not needed for duration calculation
/// - Bytes 28-31: Byte rate (little-endian) - bytes per second
/// - Bytes 40-43: Data size (little-endian) - total audio data size
/// Duration = data_size / byte_rate
fn get_wav_duration(wav_bytes : Bytes) -> Double {
let byte_rate = read_u32_le(wav_bytes, 28)
let data_size = read_u32_le(wav_bytes, 40)
data_size.to_double() / byte_rate.to_double()
}
///|
/// Build target directory path from course name
pub fn target_dir(course_name : String) -> String {
path_join(["mdcoursetarget", course_name])
}
///|
/// Build audio directory path from target directory
fn audio_dir(target : String) -> String {
path_join([target, "audio"])
}
///|
/// Build slides directory path from target directory
fn slides_dir(target : String) -> String {
path_join([target, "slides"])
}
///|
/// Build manifest file path
fn manifest_path(target : String) -> String {
path_join([target, "manifest.json"])
}
///|
/// Build slide image path
fn slide_image_path(target : String, slide_id : String) -> String {
path_join([slides_dir(target), "slide-\{slide_id}.png"])
}
///|
/// Build audio file path for a slide
fn slide_audio_path(target : String, slide_id : String) -> String {
path_join([audio_dir(target), "slide-\{slide_id}.wav"])
}
///|
/// Build audio metadata path for a slide
fn slide_meta_path(target : String, slide_id : String) -> String {
path_join([audio_dir(target), "slide-\{slide_id}.meta.json"])
}
///|
/// Build SSML file path for a slide
fn slide_ssml_path(target : String, slide_id : String) -> String {
path_join([audio_dir(target), "slide-\{slide_id}.ssml"])
}
///|
/// Build SSML file path for video after a slide (with video index)
fn video_ssml_path_indexed(
target : String,
slide_id : String,
video_index : Int,
) -> String {
path_join([audio_dir(target), "video-after-\{slide_id}-\{video_index}.ssml"])
}
///|
/// Build audio file path for video after a slide (with video index)
fn video_audio_path_indexed(
target : String,
slide_id : String,
video_index : Int,
) -> String {
path_join([audio_dir(target), "video-after-\{slide_id}-\{video_index}.wav"])
}
///|
/// Build audio metadata path for video after a slide (with video index)
fn video_meta_path_indexed(
target : String,
slide_id : String,
video_index : Int,
) -> String {
path_join([
audio_dir(target),
"video-after-\{slide_id}-\{video_index}.meta.json",
])
}
///|
/// Check if video has an audio stream using ffprobe
async fn has_audio_stream(video_path : String) -> Bool {
let args : Array[String] = [
"-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type",
"-of", "default=noprint_wrappers=1:nokey=1", video_path,
]
let (exit_code, stdout, _stderr) = @process.collect_output("ffprobe", args)
if exit_code != 0 {
return false
}
let output = stdout.text().trim(char_set=" \t\n")
output.to_owned() == "audio"
}
///|
/// Get video duration using ffprobe
pub async fn get_video_duration(video_path : String) -> Double {
let args : Array[String] = [
"-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1",
video_path,
]
let (exit_code, stdout, _stderr) = @process.collect_output("ffprobe", args)
if exit_code != 0 {
return 0.0
}
let duration_str = stdout.text().trim(char_set=" \t\n")
@string.parse_double(duration_str.to_owned()) catch {
_ => 0.0
}
}
///|
/// Estimate video size in MB (rough approximation)
/// Assumes H.264 with ~3 Mbps bitrate
fn estimate_video_size(
duration_seconds : Double,
resolution : Resolution,
) -> Double {
// Base bitrate in Mbps
let base_bitrate = match resolution.width {
3840 => 10.0 // 4K
2560 => 6.0 // 1440p
1920 => 3.0 // 1080p
1280 => 1.5 // 720p
_ => 1.0 // 480p or lower
}
// MB = (bitrate in Mbps * duration in seconds) / 8
base_bitrate * duration_seconds / 8.0
}
///|
/// Get segment metadata file path for a slide segment
fn slide_segment_meta_path(output_dir : String, slide_id : String) -> String {
path_join([output_dir, "segments", "slide-\{slide_id}.meta.json"])
}
///|
/// Get segment metadata file path for a video segment
/// video_index: 1-based index for videos after the same slide (1st video, 2nd video, etc.)
fn video_segment_meta_path(
output_dir : String,
slide_id : String,
video_index : Int,
) -> String {
path_join([
output_dir,
"segments",
"video-after-\{slide_id}-\{video_index}.meta.json",
])
}
///|
/// Hash file contents using SHA256
pub async fn hash_file(file_path : String) -> String {
if !@fs.exists(file_path) {
return ""
}
let bytes = @fs.read_file(file_path).binary()
let digest = @sha256.Digest::new()
for b in bytes {
digest.write(b)
}
digest.check_sum()
}