From 1db6150ab5e4347afa74505d58459cdca3f08fb3 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Fri, 11 Sep 2026 14:32:08 -0400 Subject: [PATCH] feat: add bounded HandBrake video adapter --- CHANGELOG.md | 9 +- Cargo.lock | 70 +- README.md | 1 + crates/renderflow-core/Cargo.toml | 1 + .../renderflow-core/data/adapter-packs.yaml | 29 +- .../renderflow-core/data/tool-registry.yaml | 22 + crates/renderflow-core/src/app.rs | 48 +- crates/renderflow-core/src/cli.rs | 80 ++- crates/renderflow-core/src/commands/mod.rs | 1 + crates/renderflow-core/src/commands/video.rs | 58 ++ crates/renderflow-core/src/lib.rs | 9 + crates/renderflow-core/src/process.rs | 4 + crates/renderflow-core/src/video/handbrake.rs | 659 ++++++++++++++++++ crates/renderflow-core/src/video/mod.rs | 15 + docs/cli-reference/index.md | 1 + docs/handbrake-adapter.md | 91 +++ docs/index.md | 6 + docs/provider-contract.md | 2 + mkdocs.yml | 1 + ...erflow-handbrake-capability-v1.schema.json | 63 ++ ...derflow-handbrake-transform-v1.schema.json | 133 ++++ 21 files changed, 1295 insertions(+), 8 deletions(-) create mode 100644 crates/renderflow-core/src/commands/video.rs create mode 100644 crates/renderflow-core/src/video/handbrake.rs create mode 100644 crates/renderflow-core/src/video/mod.rs create mode 100644 docs/handbrake-adapter.md create mode 100644 schemas/renderflow-handbrake-capability-v1.schema.json create mode 100644 schemas/renderflow-handbrake-transform-v1.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4934eb7..b581398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Features + +- Add a typed, bounded HandBrakeCLI adapter for whole-file video derivatives, + with progress, cancellation, provenance, Flow projection, and an explicit + Aniflow temporal-workflow boundary. + ## [0.1.0] - 2026-03-30 ### Features @@ -45,4 +53,3 @@ All notable changes to this project will be documented in this file. - Update select_strategy to accept references instead of owned values - diff --git a/Cargo.lock b/Cargo.lock index cdf4946..9b9b7e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bstr" version = "1.12.1" @@ -177,6 +186,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.44" @@ -405,6 +420,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -432,6 +458,18 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -901,9 +939,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -969,6 +1007,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify" version = "6.1.1" @@ -1023,6 +1073,21 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.4" @@ -1352,6 +1417,7 @@ dependencies = [ "anyhow", "clap", "criterion", + "ctrlc", "indicatif", "itertools 0.14.0", "notify", diff --git a/README.md b/README.md index d450483..350a064 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ At its core, Renderflow models every transformation as a **directed acyclic grap - 🤖 **AI transforms** — Ollama and OpenAI-compatible LLM integration with local caching - 🖼️ **Image conversion** — FFmpeg-backed format conversion across 80+ image formats (JPEG, PNG, WebP, AVIF, HEIC, EXR, and more) - 🎵 **Audio conversion** — FFmpeg-backed format conversion across 40+ audio formats (WAV, FLAC, MP3, AAC, Opus, and more) +- 🎬 **Whole-file video delivery** — Typed HandBrake presets with bounded execution, provenance, and explicit Aniflow temporal boundaries - 🧩 **Custom templates** — Per-output Jinja2-compatible templates via [Tera](https://keats.github.io/tera/) - 🔌 **Plugin system** — Register external transform executors at runtime without modifying core - 🖼️ **Asset management** — Automatically resolves and validates image paths diff --git a/crates/renderflow-core/Cargo.toml b/crates/renderflow-core/Cargo.toml index f1baa23..33bb67f 100644 --- a/crates/renderflow-core/Cargo.toml +++ b/crates/renderflow-core/Cargo.toml @@ -27,6 +27,7 @@ autobins = false [dependencies] clap = { version = "4", features = ["derive"] } +ctrlc = "3.4" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/renderflow-core/data/adapter-packs.yaml b/crates/renderflow-core/data/adapter-packs.yaml index 608c3c8..2a19cd2 100644 --- a/crates/renderflow-core/data/adapter-packs.yaml +++ b/crates/renderflow-core/data/adapter-packs.yaml @@ -110,6 +110,30 @@ providers: upstream: https://ffmpeg.org/ rationale: Existing local image/audio adapter; video and subtitle capabilities remain graph-advertised only when an executable edge exists. + - id: adapter.media.handbrake + runtime_tool: tool.handbrake + name: HandBrake bounded whole-file video adapter + families: [audio_video] + maturity: integrated + selection_priority: 20 + capabilities: [video.transcode.whole_file] + input_media_types: [video/*] + output_media_types: [video/mp4] + determinism: configuration_dependent + locality: local + fidelity: lossy + execution: *bounded_local + configuration_schema: + preset: allowlisted_enum + timeout_seconds: bounded_integer + capture_limit_bytes: bounded_integer + progress_interval_ms: bounded_integer + maximum_output_bytes: bounded_integer + validation: [validator.core.non_empty, validator.video.mp4_file_type_box] + provenance: [provider_id, provider_version, argv_digest, input_digest, output_digest] + upstream: https://handbrake.fr/ + rationale: HandBrakeCLI is integrated for typed whole-file delivery transforms; temporal decomposition, boundary selection, ordering, and reconstruction remain Aniflow-owned. + - id: adapter.pdf.wkhtmltopdf runtime_tool: tool.wkhtmltopdf name: wkhtmltopdf compatibility adapter @@ -393,7 +417,6 @@ evaluations: rationale: GUI-centric cross-platform footprint is a poor core dependency; prefer bounded FFmpeg and text-native subtitle adapters. - candidate: HandBrakeCLI families: [audio_video] - decision: defer + decision: adapt upstream: https://handbrake.fr/ - rationale: "Keep video transcode ownership coordinated with Aniflow through issue #345." - follow_up: "#345" + rationale: Adopt a narrow allowlisted whole-file preset surface through renderflow.process/v1 while retaining all temporal workflow ownership in Aniflow. diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index a7cea5d..bb3c086 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -107,6 +107,28 @@ tools: license_notes: "FFmpeg licensing is build-dependent; consult the exact distributed build." distribution_notes: "Used by Renderflow audio, image, and future video adapters." + - id: tool.handbrake + name: HandBrakeCLI + discovery: + kind: executable + candidates: [HandBrakeCLI] + version_args: [--version] + version: + min_inclusive: "1.6.0" + operating_systems: [linux, macos, windows] + capabilities: + - video.transcode.whole_file + input_media_types: + - video/* + output_media_types: + - video/mp4 + determinism: configuration_dependent + locality: local + fidelity: lossy + support_tier: optional + license_notes: "HandBrake is GPL-2.0-only; consult the exact distributed binary and bundled libraries." + distribution_notes: "Optional bounded whole-file delivery adapter; Aniflow owns temporal segmentation and reconstruction." + - id: tool.wkhtmltopdf name: wkhtmltopdf discovery: diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index a702d91..30626c9 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -4,8 +4,9 @@ use tracing::info; use crate::cli::{ AiCommands, Cli, Commands, EbookCommands, GraphCommands, LuluCommands, PluginCommands, - PublicationCommands, SpecCommands, ToolCommands, + PublicationCommands, SpecCommands, ToolCommands, VideoCommands, }; +use crate::video::HandBrakeLimits; use crate::{commands, transforms}; /// Initialize logging for a Renderflow CLI run. @@ -217,6 +218,51 @@ pub fn run_cli(cli: Cli) -> Result<()> { )?, }, }, + Some(Commands::Video { subcommand }) => match subcommand { + VideoCommands::Capabilities { format } => commands::video::run_capabilities(&format)?, + VideoCommands::Plan { + input, + output, + preset, + timeout_seconds, + capture_limit_bytes, + progress_interval_ms, + maximum_output_bytes, + format, + } => commands::video::run_plan( + &input, + &output, + preset.into(), + HandBrakeLimits { + timeout_seconds, + capture_limit_bytes, + progress_interval_ms, + maximum_output_bytes, + }, + &format, + )?, + VideoCommands::Transcode { + input, + output, + preset, + timeout_seconds, + capture_limit_bytes, + progress_interval_ms, + maximum_output_bytes, + format, + } => commands::video::run_transcode( + &input, + &output, + preset.into(), + HandBrakeLimits { + timeout_seconds, + capture_limit_bytes, + progress_interval_ms, + maximum_output_bytes, + }, + &format, + )?, + }, Some(Commands::Capabilities { format, transforms, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index fa08174..de2f563 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -1,6 +1,7 @@ -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use crate::optimization::OptimizationMode; +use crate::video::HandBrakePreset; /// Spec-driven document rendering engine #[derive(Parser)] @@ -280,6 +281,13 @@ pub enum Commands { subcommand: PublicationCommands, }, + /// Plan and execute bounded whole-file video transforms. + #[command(subcommand_required = true, arg_required_else_help = true)] + Video { + #[command(subcommand)] + subcommand: VideoCommands, + }, + /// List stable provider capability IDs and their implementations. Capabilities { /// Output format: text (default), json, or yaml. @@ -314,6 +322,76 @@ pub enum Commands { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum VideoPresetArgument { + #[value(name = "fast-720p30")] + Fast720p30, + #[value(name = "fast-1080p30")] + Fast1080p30, + #[value(name = "creator-1080p60")] + Creator1080p60, + #[value(name = "production-standard")] + ProductionStandard, +} + +impl From for HandBrakePreset { + fn from(value: VideoPresetArgument) -> Self { + match value { + VideoPresetArgument::Fast720p30 => Self::Fast720p30, + VideoPresetArgument::Fast1080p30 => Self::Fast1080p30, + VideoPresetArgument::Creator1080p60 => Self::Creator1080p60, + VideoPresetArgument::ProductionStandard => Self::ProductionStandard, + } + } +} + +#[derive(Subcommand)] +pub enum VideoCommands { + /// Print typed presets, ownership boundaries, and interchange contracts. + Capabilities { + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, + /// Normalize and hash a whole-file HandBrake request without executing it. + Plan { + #[arg(long, value_name = "FILE")] + input: String, + #[arg(long, value_name = "FILE")] + output: String, + #[arg(long, value_enum, default_value_t = VideoPresetArgument::Fast1080p30)] + preset: VideoPresetArgument, + #[arg(long, default_value_t = 7_200)] + timeout_seconds: u64, + #[arg(long, default_value_t = 262_144)] + capture_limit_bytes: usize, + #[arg(long, default_value_t = 1_000)] + progress_interval_ms: u64, + #[arg(long, default_value_t = 21_474_836_480)] + maximum_output_bytes: u64, + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, + /// Execute a bounded HandBrakeCLI whole-file transform. + Transcode { + #[arg(long, value_name = "FILE")] + input: String, + #[arg(long, value_name = "FILE")] + output: String, + #[arg(long, value_enum, default_value_t = VideoPresetArgument::Fast1080p30)] + preset: VideoPresetArgument, + #[arg(long, default_value_t = 7_200)] + timeout_seconds: u64, + #[arg(long, default_value_t = 262_144)] + capture_limit_bytes: usize, + #[arg(long, default_value_t = 1_000)] + progress_interval_ms: u64, + #[arg(long, default_value_t = 21_474_836_480)] + maximum_output_bytes: u64, + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, +} + #[derive(Subcommand)] pub enum PublicationCommands { /// Evaluate candidates with the bundled, offline Lulu provider pack. diff --git a/crates/renderflow-core/src/commands/mod.rs b/crates/renderflow-core/src/commands/mod.rs index 8a1c69d..082774b 100644 --- a/crates/renderflow-core/src/commands/mod.rs +++ b/crates/renderflow-core/src/commands/mod.rs @@ -9,4 +9,5 @@ pub mod publication; pub mod spec; pub mod system; pub mod tools; +pub mod video; pub mod watch; diff --git a/crates/renderflow-core/src/commands/video.rs b/crates/renderflow-core/src/commands/video.rs new file mode 100644 index 0000000..0aa905a --- /dev/null +++ b/crates/renderflow-core/src/commands/video.rs @@ -0,0 +1,58 @@ +use anyhow::Result; + +use crate::sdk::{CancellationToken, ProgressEvent, ProgressReporter}; +use crate::video::{ + execute_handbrake_with_progress, plan_handbrake, HandBrakeCapabilityContract, HandBrakeLimits, + HandBrakePreset, HandBrakeTransformRequest, +}; + +pub fn run_capabilities(format: &str) -> Result<()> { + emit(&HandBrakeCapabilityContract::builtin(), format) +} + +pub fn run_plan( + input: &str, + output: &str, + preset: HandBrakePreset, + limits: HandBrakeLimits, + format: &str, +) -> Result<()> { + let request = HandBrakeTransformRequest::new(input, output, preset).with_limits(limits); + emit(&plan_handbrake(&request)?, format) +} + +pub fn run_transcode( + input: &str, + output: &str, + preset: HandBrakePreset, + limits: HandBrakeLimits, + format: &str, +) -> Result<()> { + let cancellation = CancellationToken::new(); + let signal = cancellation.clone(); + ctrlc::set_handler(move || signal.cancel())?; + let request = HandBrakeTransformRequest::new(input, output, preset).with_limits(limits); + let reporter = CliVideoProgress; + let report = execute_handbrake_with_progress(&request, &cancellation, Some(&reporter))?; + emit(&report, format) +} + +struct CliVideoProgress; + +impl ProgressReporter for CliVideoProgress { + fn on_event(&self, event: &ProgressEvent) { + eprintln!("{}", event.message); + } +} + +fn emit(value: &T, format: &str) -> Result<()> { + match format.to_ascii_lowercase().as_str() { + "json" => println!("{}", serde_json::to_string_pretty(value)?), + "yaml" | "yml" => print!("{}", serde_yaml_ng::to_string(value)?), + "text" => print!("{}", serde_yaml_ng::to_string(value)?), + other => { + anyhow::bail!("unknown video output format '{other}'; supported: text, json, yaml") + } + } + Ok(()) +} diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index d8dac79..f192814 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -38,6 +38,7 @@ pub mod super_resolution; pub mod toolchain; pub mod transforms; pub mod validation; +pub mod video; pub use evidence::{ArtifactManifest, RunManifest}; pub use hygiene::{ @@ -58,3 +59,11 @@ pub use sdk::{ ProgressStage, ProviderCapabilities, ProviderPlan, RenderflowError, RenderflowProvider, SavedRunAssessment, }; +pub use video::{ + execute_handbrake, execute_handbrake_with_progress, plan_handbrake, + HandBrakeCapabilityContract, HandBrakeLimits, HandBrakePreset, HandBrakePresetContract, + HandBrakeTransformPlan, HandBrakeTransformReport, HandBrakeTransformRequest, + HandBrakeValidation, ANIFLOW_RECONSTRUCT_CAPABILITY_ID_V1, ANIFLOW_SEGMENT_CAPABILITY_ID_V1, + HANDBRAKE_CAPABILITY_CONTRACT_SCHEMA_V1, HANDBRAKE_PROVIDER_ID, HANDBRAKE_TOOL_ID, + HANDBRAKE_TRANSFORM_CAPABILITY_ID_V1, HANDBRAKE_TRANSFORM_SCHEMA_V1, +}; diff --git a/crates/renderflow-core/src/process.rs b/crates/renderflow-core/src/process.rs index c0aad28..1f3b098 100644 --- a/crates/renderflow-core/src/process.rs +++ b/crates/renderflow-core/src/process.rs @@ -110,6 +110,10 @@ impl ProcessCancellationToken { pub fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::SeqCst) } + + pub(crate) fn from_shared(cancelled: Arc) -> Self { + Self { cancelled } + } } /// How stdin is connected to the child. diff --git a/crates/renderflow-core/src/video/handbrake.rs b/crates/renderflow-core/src/video/handbrake.rs new file mode 100644 index 0000000..9f14e0b --- /dev/null +++ b/crates/renderflow-core/src/video/handbrake.rs @@ -0,0 +1,659 @@ +//! Bounded HandBrakeCLI adapter for whole-file video delivery transforms. + +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::evidence::{ + sha256_serialized, unix_time_ms, DigestEvidence, FlowArtifactV1, FlowProducerV1, + FLOW_ARTIFACT_SCHEMA_V1, +}; +use crate::process::{ + ProcessCancellationToken, ProcessExecutor, ProcessExpectedOutput, ProcessNetworkPolicy, + ProcessRequest, ProcessTermination, +}; +use crate::sdk::{ + CancellationToken, ProgressEvent, ProgressReporter, ProgressStage, PROGRESS_EVENT_V1, +}; +use crate::toolchain::ToolRegistry; + +pub const HANDBRAKE_TRANSFORM_SCHEMA_V1: &str = "renderflow.handbrake-transform/v1"; +pub const HANDBRAKE_CAPABILITY_CONTRACT_SCHEMA_V1: &str = "renderflow.handbrake-capability/v1"; +pub const HANDBRAKE_TRANSFORM_CAPABILITY_ID_V1: &str = "video.transcode.whole_file"; +pub const HANDBRAKE_PROVIDER_ID: &str = "adapter.media.handbrake"; +pub const HANDBRAKE_TOOL_ID: &str = "tool.handbrake"; +pub const ANIFLOW_SEGMENT_CAPABILITY_ID_V1: &str = "media.video.segment/v1"; +pub const ANIFLOW_RECONSTRUCT_CAPABILITY_ID_V1: &str = "media.video.reconstruct/v1"; + +const MINIMUM_CAPTURE_BYTES: usize = 4 * 1024; +const MAXIMUM_CAPTURE_BYTES: usize = 4 * 1024 * 1024; +const MAXIMUM_TIMEOUT_SECONDS: u64 = 24 * 60 * 60; +const MAXIMUM_OUTPUT_BYTES_LIMIT: u64 = 1024 * 1024 * 1024 * 1024; + +/// Allowlisted HandBrake presets with stable Renderflow identifiers. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum HandBrakePreset { + Fast720p30, + #[default] + Fast1080p30, + Creator1080p60, + ProductionStandard, +} + +impl HandBrakePreset { + pub const fn id(self) -> &'static str { + match self { + Self::Fast720p30 => "fast_720p30", + Self::Fast1080p30 => "fast_1080p30", + Self::Creator1080p60 => "creator_1080p60", + Self::ProductionStandard => "production_standard", + } + } + + pub const fn handbrake_name(self) -> &'static str { + match self { + Self::Fast720p30 => "Fast 720p30", + Self::Fast1080p30 => "Fast 1080p30", + Self::Creator1080p60 => "Creator 1080p60", + Self::ProductionStandard => "Production Standard", + } + } + + pub const fn purpose(self) -> &'static str { + match self { + Self::Fast720p30 => "compact broadly compatible delivery", + Self::Fast1080p30 => "default broadly compatible delivery", + Self::Creator1080p60 => "high-quality creator-platform upload master", + Self::ProductionStandard => "high-bitrate editing and mezzanine handoff", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandBrakePresetContract { + pub id: String, + pub handbrake_name: String, + pub purpose: String, + pub container: String, + pub whole_file_only: bool, +} + +impl From for HandBrakePresetContract { + fn from(preset: HandBrakePreset) -> Self { + Self { + id: preset.id().to_string(), + handbrake_name: preset.handbrake_name().to_string(), + purpose: preset.purpose().to_string(), + container: "mp4".to_string(), + whole_file_only: true, + } + } +} + +/// Resource policy applied to one HandBrake process. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandBrakeLimits { + pub timeout_seconds: u64, + pub capture_limit_bytes: usize, + pub progress_interval_ms: u64, + pub maximum_output_bytes: u64, +} + +impl Default for HandBrakeLimits { + fn default() -> Self { + Self { + timeout_seconds: 2 * 60 * 60, + capture_limit_bytes: 256 * 1024, + progress_interval_ms: 1_000, + maximum_output_bytes: 20 * 1024 * 1024 * 1024, + } + } +} + +/// Typed request for a single whole-file transform. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandBrakeTransformRequest { + pub input: PathBuf, + pub output: PathBuf, + #[serde(default)] + pub preset: HandBrakePreset, + #[serde(default)] + pub limits: HandBrakeLimits, +} + +impl HandBrakeTransformRequest { + pub fn new( + input: impl Into, + output: impl Into, + preset: HandBrakePreset, + ) -> Self { + Self { + input: input.into(), + output: output.into(), + preset, + limits: HandBrakeLimits::default(), + } + } + + pub fn with_limits(mut self, limits: HandBrakeLimits) -> Self { + self.limits = limits; + self + } +} + +/// Side-effect-free normalized execution plan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandBrakeTransformPlan { + pub schema_version: String, + pub capability_id: String, + pub provider_id: String, + pub tool_id: String, + pub temporal_scope: String, + pub input: PathBuf, + pub input_digest: DigestEvidence, + pub input_size_bytes: u64, + pub output: PathBuf, + pub preset: HandBrakePresetContract, + pub arguments: Vec, + pub limits: HandBrakeLimits, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandBrakeValidation { + pub non_empty: bool, + pub mp4_file_type_box: bool, + pub within_output_limit: bool, +} + +/// Durable provenance for a successful transform. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandBrakeTransformReport { + pub schema_version: String, + pub capability_id: String, + pub provider_id: String, + pub renderflow_version: String, + pub handbrake_version: String, + pub temporal_scope: String, + pub preset: HandBrakePresetContract, + pub input: PathBuf, + pub input_digest: DigestEvidence, + pub input_size_bytes: u64, + pub output: PathBuf, + pub provenance: PathBuf, + pub output_digest: DigestEvidence, + pub output_size_bytes: u64, + pub argv_digest: DigestEvidence, + pub started_at_unix_ms: u64, + pub completed_at_unix_ms: u64, + pub duration_ms: u64, + pub stdout_total_bytes: u64, + pub stderr_total_bytes: u64, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub validation: HandBrakeValidation, + pub flow_artifact: FlowArtifactV1, + pub aniflow_segment_capability: String, + pub aniflow_reconstruct_capability: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandBrakeCapabilityContract { + pub schema_version: String, + pub capability_id: String, + pub provider_id: String, + pub tool_id: String, + pub temporal_scope: String, + pub owned_transforms: Vec, + pub excluded_responsibilities: Vec, + pub presets: Vec, + pub progress_schema: String, + pub flow_artifact_schema: String, + pub aniflow_segment_capability: String, + pub aniflow_reconstruct_capability: String, +} + +impl HandBrakeCapabilityContract { + pub fn builtin() -> Self { + Self { + schema_version: HANDBRAKE_CAPABILITY_CONTRACT_SCHEMA_V1.to_string(), + capability_id: HANDBRAKE_TRANSFORM_CAPABILITY_ID_V1.to_string(), + provider_id: HANDBRAKE_PROVIDER_ID.to_string(), + tool_id: HANDBRAKE_TOOL_ID.to_string(), + temporal_scope: "whole_file".to_string(), + owned_transforms: vec![ + "container_and_codec_delivery_transcode".to_string(), + "resolution_and_frame_rate_limit_from_typed_preset".to_string(), + "web_fast_start".to_string(), + "metadata_and_chapter_preservation".to_string(), + ], + excluded_responsibilities: vec![ + "temporal_decomposition".to_string(), + "segment_boundary_selection".to_string(), + "segment_manifest_ordering".to_string(), + "segment_reconstruction".to_string(), + ], + presets: [ + HandBrakePreset::Fast720p30, + HandBrakePreset::Fast1080p30, + HandBrakePreset::Creator1080p60, + HandBrakePreset::ProductionStandard, + ] + .into_iter() + .map(HandBrakePresetContract::from) + .collect(), + progress_schema: PROGRESS_EVENT_V1.to_string(), + flow_artifact_schema: FLOW_ARTIFACT_SCHEMA_V1.to_string(), + aniflow_segment_capability: ANIFLOW_SEGMENT_CAPABILITY_ID_V1.to_string(), + aniflow_reconstruct_capability: ANIFLOW_RECONSTRUCT_CAPABILITY_ID_V1.to_string(), + } + } +} + +pub fn plan_handbrake(request: &HandBrakeTransformRequest) -> Result { + validate_request(request)?; + let input = request + .input + .canonicalize() + .with_context(|| format!("failed to resolve input '{}'", request.input.display()))?; + let output = absolute_path(&request.output)?; + let input_size_bytes = fs::metadata(&input)?.len(); + let input_digest = sha256_file(&input)?; + let arguments = build_arguments(&input, &output, request.preset)?; + Ok(HandBrakeTransformPlan { + schema_version: HANDBRAKE_TRANSFORM_SCHEMA_V1.to_string(), + capability_id: HANDBRAKE_TRANSFORM_CAPABILITY_ID_V1.to_string(), + provider_id: HANDBRAKE_PROVIDER_ID.to_string(), + tool_id: HANDBRAKE_TOOL_ID.to_string(), + temporal_scope: "whole_file".to_string(), + input, + input_digest, + input_size_bytes, + output, + preset: request.preset.into(), + arguments, + limits: request.limits.clone(), + }) +} + +pub fn execute_handbrake(request: &HandBrakeTransformRequest) -> Result { + execute_handbrake_with_progress(request, &CancellationToken::new(), None) +} + +pub fn execute_handbrake_with_progress( + request: &HandBrakeTransformRequest, + cancellation: &CancellationToken, + reporter: Option<&dyn ProgressReporter>, +) -> Result { + let plan = plan_handbrake(request)?; + if cancellation.is_cancelled() { + emit_progress( + reporter, + ProgressStage::Cancelled, + "HandBrake transform cancelled", + "cancelled", + ); + bail!("HandBrake transform was cancelled before launch"); + } + if plan.output.exists() { + bail!("output already exists: {}", plan.output.display()); + } + let provenance_path = provenance_path(&plan.output)?; + if provenance_path.exists() { + bail!( + "provenance output already exists: {}", + provenance_path.display() + ); + } + let parent = plan + .output + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("failed to create output directory '{}'", parent.display()))?; + let temporary = temporary_output(parent)?; + let actual_arguments = build_arguments(&plan.input, &temporary, request.preset)?; + let argv_digest = sha256_serialized(&actual_arguments)?; + let process_cancellation = ProcessCancellationToken::from_shared(cancellation.flag()); + let inventory = ToolRegistry::builtins().assess_ids_current([HANDBRAKE_TOOL_ID]); + let availability = inventory + .get(HANDBRAKE_TOOL_ID) + .context("HandBrake tool registry entry is missing")?; + if !availability.is_available() { + remove_file_if_present(&temporary)?; + bail!("HandBrakeCLI is unavailable: {}", availability.summary()); + } + let executable = availability + .selected_executable + .clone() + .context("available HandBrake provider did not select an executable")?; + let handbrake_version = availability + .version_line + .clone() + .unwrap_or_else(|| "version unavailable".to_string()); + let process_request = ProcessRequest::direct(executable) + .args(actual_arguments) + .timeout(Duration::from_secs(request.limits.timeout_seconds)) + .capture_limit(request.limits.capture_limit_bytes) + .cancellation(process_cancellation) + .network_policy(ProcessNetworkPolicy::Deny) + .sandbox_profile("renderflow.handbrake.whole-file/v1") + .expect_output(ProcessExpectedOutput::file(&temporary).require_non_empty()); + let executor = ProcessExecutor::new(); + let started_at_unix_ms = unix_time_ms(); + let started = Instant::now(); + emit_progress( + reporter, + ProgressStage::Executing, + "HandBrake transform started", + "running", + ); + let (sender, receiver) = mpsc::sync_channel(1); + let worker = thread::spawn(move || { + let _ = sender.send(executor.execute(process_request)); + }); + let process_result = loop { + match receiver.recv_timeout(Duration::from_millis(request.limits.progress_interval_ms)) { + Ok(result) => break Some(result), + Err(mpsc::RecvTimeoutError::Timeout) => emit_progress( + reporter, + ProgressStage::Executing, + format!( + "HandBrake transform running for {} ms", + started.elapsed().as_millis() + ), + "running", + ), + Err(mpsc::RecvTimeoutError::Disconnected) => { + break None; + } + } + }; + if worker.join().is_err() { + remove_file_if_present(&temporary)?; + bail!("HandBrake process worker panicked"); + } + let result = match process_result { + Some(Ok(result)) => result, + Some(Err(error)) => { + remove_file_if_present(&temporary)?; + return Err(error.into()); + } + None => { + remove_file_if_present(&temporary)?; + bail!("HandBrake process worker disconnected"); + } + }; + if result.termination() == ProcessTermination::Cancelled { + remove_file_if_present(&temporary)?; + emit_progress( + reporter, + ProgressStage::Cancelled, + "HandBrake transform cancelled", + "cancelled", + ); + bail!("HandBrake transform was cancelled"); + } + if let Err(error) = result.ensure_success() { + remove_file_if_present(&temporary)?; + bail!("{error}"); + } + + let output_size_bytes = fs::metadata(&temporary)?.len(); + let validation = HandBrakeValidation { + non_empty: output_size_bytes > 0, + mp4_file_type_box: has_mp4_file_type_box(&temporary)?, + within_output_limit: output_size_bytes <= request.limits.maximum_output_bytes, + }; + if !validation.non_empty || !validation.mp4_file_type_box || !validation.within_output_limit { + remove_file_if_present(&temporary)?; + bail!("HandBrake output failed validation: {validation:?}"); + } + if plan.output.exists() { + remove_file_if_present(&temporary)?; + bail!("output appeared during execution; refusing to replace it"); + } + let output_digest = sha256_file(&temporary)?; + fs::rename(&temporary, &plan.output).with_context(|| { + format!( + "failed to atomically publish HandBrake output '{}'", + plan.output.display() + ) + })?; + let completed_at_unix_ms = unix_time_ms(); + let flow_artifact = FlowArtifactV1 { + schema_version: FLOW_ARTIFACT_SCHEMA_V1.to_string(), + artifact_id: format!("artifact:sha256-{}", output_digest.value), + role: "video_delivery".to_string(), + media_type: "video/mp4".to_string(), + digest: output_digest.clone(), + size_bytes: output_size_bytes, + producer: FlowProducerV1 { + owner: "renderflow".to_string(), + capability_id: HANDBRAKE_TRANSFORM_CAPABILITY_ID_V1.to_string(), + provider_version: env!("CARGO_PKG_VERSION").to_string(), + }, + sources: vec![format!("artifact:sha256-{}", plan.input_digest.value)], + }; + let report = HandBrakeTransformReport { + schema_version: HANDBRAKE_TRANSFORM_SCHEMA_V1.to_string(), + capability_id: HANDBRAKE_TRANSFORM_CAPABILITY_ID_V1.to_string(), + provider_id: HANDBRAKE_PROVIDER_ID.to_string(), + renderflow_version: env!("CARGO_PKG_VERSION").to_string(), + handbrake_version, + temporal_scope: "whole_file".to_string(), + preset: plan.preset, + input: plan.input, + input_digest: plan.input_digest, + input_size_bytes: plan.input_size_bytes, + output: plan.output, + provenance: provenance_path.clone(), + output_digest, + output_size_bytes, + argv_digest, + started_at_unix_ms, + completed_at_unix_ms, + duration_ms: result.duration_ms(), + stdout_total_bytes: result.stdout().total_bytes(), + stderr_total_bytes: result.stderr().total_bytes(), + stdout_truncated: result.stdout().truncated(), + stderr_truncated: result.stderr().truncated(), + validation, + flow_artifact, + aniflow_segment_capability: ANIFLOW_SEGMENT_CAPABILITY_ID_V1.to_string(), + aniflow_reconstruct_capability: ANIFLOW_RECONSTRUCT_CAPABILITY_ID_V1.to_string(), + }; + if let Err(error) = write_json_atomic(&provenance_path, &report) { + remove_file_if_present(&report.output)?; + return Err(error); + } + emit_progress( + reporter, + ProgressStage::Completed, + "HandBrake transform completed", + "complete", + ); + Ok(report) +} + +fn validate_request(request: &HandBrakeTransformRequest) -> Result<()> { + if !request.input.is_file() { + bail!("input video does not exist: {}", request.input.display()); + } + let input_extension = extension(&request.input)?; + if !matches!( + input_extension.as_str(), + "avi" | "m4v" | "mkv" | "mov" | "mp4" | "webm" + ) { + bail!("unsupported HandBrake input extension '{input_extension}'"); + } + if extension(&request.output)? != "mp4" { + bail!("bounded HandBrake presets require an .mp4 output"); + } + if request.limits.timeout_seconds == 0 + || request.limits.timeout_seconds > MAXIMUM_TIMEOUT_SECONDS + { + bail!("timeout_seconds must be between 1 and {MAXIMUM_TIMEOUT_SECONDS}"); + } + if !(MINIMUM_CAPTURE_BYTES..=MAXIMUM_CAPTURE_BYTES) + .contains(&request.limits.capture_limit_bytes) + { + bail!( + "capture_limit_bytes must be between {MINIMUM_CAPTURE_BYTES} and {MAXIMUM_CAPTURE_BYTES}" + ); + } + if !(100..=60_000).contains(&request.limits.progress_interval_ms) { + bail!("progress_interval_ms must be between 100 and 60000"); + } + if request.limits.maximum_output_bytes == 0 + || request.limits.maximum_output_bytes > MAXIMUM_OUTPUT_BYTES_LIMIT + { + bail!("maximum_output_bytes must be between 1 and {MAXIMUM_OUTPUT_BYTES_LIMIT}"); + } + let input = request.input.canonicalize()?; + let output = absolute_path(&request.output)?; + if output.exists() && output.canonicalize()? == input { + bail!("input and output must be different files"); + } + Ok(()) +} + +fn build_arguments(input: &Path, output: &Path, preset: HandBrakePreset) -> Result> { + let input = input + .to_str() + .context("HandBrake input path must be valid UTF-8")?; + let output = output + .to_str() + .context("HandBrake output path must be valid UTF-8")?; + Ok(vec![ + "--json".to_string(), + "--input".to_string(), + input.to_string(), + "--output".to_string(), + output.to_string(), + "--preset".to_string(), + preset.handbrake_name().to_string(), + "--format".to_string(), + "av_mp4".to_string(), + "--optimize".to_string(), + "--markers".to_string(), + "--keep-metadata".to_string(), + ]) +} + +fn emit_progress( + reporter: Option<&dyn ProgressReporter>, + stage: ProgressStage, + message: impl Into, + state: &str, +) { + if let Some(reporter) = reporter { + reporter.on_event(&ProgressEvent { + schema_version: PROGRESS_EVENT_V1.to_string(), + stage, + message: message.into(), + run_id: None, + step_id: Some("handbrake-whole-file-transcode".to_string()), + artifact_ids: Vec::new(), + state: Some(state.to_string()), + diagnostics: Vec::new(), + }); + } +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path) + .with_context(|| format!("failed to open '{}' for hashing", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 1024 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(DigestEvidence { + algorithm: "sha256".to_string(), + value: format!("{:x}", hasher.finalize()), + }) +} + +fn has_mp4_file_type_box(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut prefix = [0_u8; 64]; + let read = file.read(&mut prefix)?; + Ok(prefix[..read].windows(4).any(|window| window == b"ftyp")) +} + +fn extension(path: &Path) -> Result { + path.extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .context("video path requires a UTF-8 file extension") +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn temporary_output(parent: &Path) -> Result { + let temporary = tempfile::Builder::new() + .prefix(".renderflow-handbrake-") + .suffix(".mp4") + .tempfile_in(parent)?; + let path = temporary.path().to_path_buf(); + drop(temporary); + Ok(path) +} + +fn provenance_path(output: &Path) -> Result { + let name = output + .file_name() + .and_then(|value| value.to_str()) + .context("output filename must be valid UTF-8")?; + Ok(output.with_file_name(format!("{name}.renderflow.json"))) +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { + let parent = path + .parent() + .filter(|value| !value.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + serde_json::to_writer_pretty(&mut temporary, value)?; + temporary.as_file().sync_all()?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("failed to publish provenance '{}'", path.display()))?; + Ok(()) +} + +fn remove_file_if_present(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} diff --git a/crates/renderflow-core/src/video/mod.rs b/crates/renderflow-core/src/video/mod.rs new file mode 100644 index 0000000..8d000d3 --- /dev/null +++ b/crates/renderflow-core/src/video/mod.rs @@ -0,0 +1,15 @@ +//! Whole-file video delivery transforms. +//! +//! Temporal decomposition and reconstruction intentionally remain outside this +//! module and are owned by Aniflow. + +mod handbrake; + +pub use handbrake::{ + execute_handbrake, execute_handbrake_with_progress, plan_handbrake, + HandBrakeCapabilityContract, HandBrakeLimits, HandBrakePreset, HandBrakePresetContract, + HandBrakeTransformPlan, HandBrakeTransformReport, HandBrakeTransformRequest, + HandBrakeValidation, ANIFLOW_RECONSTRUCT_CAPABILITY_ID_V1, ANIFLOW_SEGMENT_CAPABILITY_ID_V1, + HANDBRAKE_CAPABILITY_CONTRACT_SCHEMA_V1, HANDBRAKE_PROVIDER_ID, HANDBRAKE_TOOL_ID, + HANDBRAKE_TRANSFORM_CAPABILITY_ID_V1, HANDBRAKE_TRANSFORM_SCHEMA_V1, +}; diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 4b62062..c51a32b 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -12,6 +12,7 @@ This section documents command syntax, flags, and behavior. - [`plugin`](plugin.md) - [`ai`](ai.md) - [`publication`](publication.md) +- [`video`](../handbrake-adapter.md) ## Logging flags diff --git a/docs/handbrake-adapter.md b/docs/handbrake-adapter.md new file mode 100644 index 0000000..2d1ea42 --- /dev/null +++ b/docs/handbrake-adapter.md @@ -0,0 +1,91 @@ +# Bounded HandBrake video adapter + +Renderflow exposes `video.transcode.whole_file` through +`adapter.media.handbrake`. The adapter creates whole-file MP4 delivery or +mezzanine derivatives. It does not select time ranges, split media, order +segments, or reconstruct segments. + +## Ownership boundary + +| Concern | Owner | Contract | +| --- | --- | --- | +| Whole-file delivery transcode | Renderflow | `video.transcode.whole_file` | +| Temporal decomposition and boundary accuracy | Aniflow | `media.video.segment/v1` | +| Ordered segment manifest and reconstruction | Aniflow | `media.video.reconstruct/v1` | +| Cross-provider sequencing and artifact routing | Flow | `flow.artifact/v1` | + +Aniflow may send Renderflow either the original whole video before splitting or +a validated reconstructed whole video after joining. Renderflow never accepts +an Aniflow plan or segment manifest as a substitute for a video input. Flow +must treat the returned `flow_artifact` as a new lossy derivative and retain +the source relationship included in that record. + +## Typed presets + +| Renderflow ID | HandBrake preset | Intended output | +| --- | --- | --- | +| `fast_720p30` | `Fast 720p30` | Compact compatible MP4 | +| `fast_1080p30` | `Fast 1080p30` | Default compatible MP4 | +| `creator_1080p60` | `Creator 1080p60` | High-quality creator upload master | +| `production_standard` | `Production Standard` | Editing or mezzanine handoff | + +These stable IDs map to an allowlist of official preset names. Arbitrary +HandBrake arguments are not accepted. The registry requires HandBrake 1.6.0 or +newer because the Creator preset naming was standardized in that release. + +Inspect the contract or plan a transform without running HandBrake: + +```bash +renderflow video capabilities --format json + +renderflow video plan \ + --input "source.mp4" \ + --output "delivery.mp4" \ + --preset "fast-1080p30" \ + --format json +``` + +Execute the planned shape: + +```bash +renderflow video transcode \ + --input "source.mp4" \ + --output "delivery.mp4" \ + --preset "creator-1080p60" \ + --timeout-seconds 7200 \ + --capture-limit-bytes 262144 \ + --progress-interval-ms 1000 \ + --maximum-output-bytes 21474836480 \ + --format json +``` + +## Process and artifact safety + +`HandBrakeCLI` runs directly through `renderflow.process/v1`; no shell parses +the command. The request declares a wall-clock timeout, bounded stdout and +stderr capture, a progress heartbeat interval, denied network intent, and a +maximum accepted output size. Each adapter request starts only one child +process. Ctrl+C uses the same cancellation token as SDK callers and terminates +the process tree. + +HandBrake writes to a randomized temporary MP4 in the destination directory. +Renderflow requires a non-empty file, an MP4 `ftyp` box, and the configured +size bound before atomically publishing it. Existing output and provenance +files are never replaced. A successful run writes `.renderflow.json` +with provider/tool versions, preset identity, input/output SHA-256 digests, +argv digest, timing, bounded-output evidence, validation, and a +`flow.artifact/v1` projection. + +Progress uses `renderflow.progress/v1`. Heartbeats report elapsed execution +time without claiming an unreliable percentage; the durable transform report +is authoritative. + +The machine-readable surfaces are published as the +[`renderflow.handbrake-capability/v1` schema](https://github.com/egohygiene/renderflow/blob/main/schemas/renderflow-handbrake-capability-v1.schema.json) +and the +[`renderflow.handbrake-transform/v1` schema](https://github.com/egohygiene/renderflow/blob/main/schemas/renderflow-handbrake-transform-v1.schema.json). + +## Sources + +- [HandBrake command-line reference](https://handbrake.fr/docs/en/latest/cli/command-line-reference.html) +- [HandBrake official presets](https://handbrake.fr/docs/en/latest/technical/official-presets.html) diff --git a/docs/index.md b/docs/index.md index 1471f54..d97dcb4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,12 @@ When a config includes `transforms: path/to/transforms.yaml`, Renderflow can bui - **AI transforms** support `ollama` and `openai` backends, prompt templates, cache files, artifact output, and API key resolution via environment variables. - **Plugins** are runtime extensions that implement the `PluginExecutor` trait and register metadata/capabilities in a `PluginRegistry`. +### Whole-file video delivery + +The bounded [HandBrake adapter](handbrake-adapter.md) exposes typed MP4 +delivery presets through `video.transcode.whole_file`. Aniflow remains the +owner of temporal segmentation, segment manifests, and reconstruction. + ## Start here - [Installation](getting-started/installation.md) diff --git a/docs/provider-contract.md b/docs/provider-contract.md index f818a4a..9f16606 100644 --- a/docs/provider-contract.md +++ b/docs/provider-contract.md @@ -40,5 +40,7 @@ Progress events include their schema version and, when available, run ID, step I - `schemas/renderflow-checkpoints-v1.schema.json` describes durable checkpoint state. - `schemas/renderflow-intake-v1.schema.json` describes universal input identity, detection, inspection, and extracted-child evidence. - `schemas/renderflow-hygiene-v1.schema.json` describes non-destructive publication-hygiene decisions and safe findings. +- `schemas/renderflow-handbrake-capability-v1.schema.json` describes the typed whole-file video ownership boundary and presets. +- `schemas/renderflow-handbrake-transform-v1.schema.json` describes HandBrake plans, provenance reports, and embedded Flow artifacts. - `schemas/renderflow-run-v1.schema.json` describes authoritative run evidence. - `RunManifest::flow_artifacts_v1()` projects outputs into `flow.artifact/v1` without importing Flow source. diff --git a/mkdocs.yml b/mkdocs.yml index 3571a16..fdf6a68 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - EPUB and KEPUB Derivatives: user-guide/ebook-derivatives.md - Magazine Publications: user-guide/magazine-publications.md - Lulu Publication Pack: user-guide/lulu-publication-pack.md + - Whole-file Video: handbrake-adapter.md - Super Resolution: user-guide/super-resolution.md - Upscayl Models: user-guide/upscayl-models.md - Pipelines: user-guide/pipelines.md diff --git a/schemas/renderflow-handbrake-capability-v1.schema.json b/schemas/renderflow-handbrake-capability-v1.schema.json new file mode 100644 index 0000000..cd84ab4 --- /dev/null +++ b/schemas/renderflow-handbrake-capability-v1.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-handbrake-capability-v1.schema.json", + "title": "Renderflow HandBrake capability contract v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "capability_id", + "provider_id", + "tool_id", + "temporal_scope", + "owned_transforms", + "excluded_responsibilities", + "presets", + "progress_schema", + "flow_artifact_schema", + "aniflow_segment_capability", + "aniflow_reconstruct_capability" + ], + "properties": { + "schema_version": { "const": "renderflow.handbrake-capability/v1" }, + "capability_id": { "const": "video.transcode.whole_file" }, + "provider_id": { "const": "adapter.media.handbrake" }, + "tool_id": { "const": "tool.handbrake" }, + "temporal_scope": { "const": "whole_file" }, + "owned_transforms": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + }, + "excluded_responsibilities": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + }, + "presets": { + "type": "array", + "items": { "$ref": "#/$defs/preset" }, + "minItems": 1 + }, + "progress_schema": { "const": "renderflow.progress/v1" }, + "flow_artifact_schema": { "const": "flow.artifact/v1" }, + "aniflow_segment_capability": { "const": "media.video.segment/v1" }, + "aniflow_reconstruct_capability": { "const": "media.video.reconstruct/v1" } + }, + "$defs": { + "preset": { + "type": "object", + "additionalProperties": false, + "required": ["id", "handbrake_name", "purpose", "container", "whole_file_only"], + "properties": { + "id": { "enum": ["fast_720p30", "fast_1080p30", "creator_1080p60", "production_standard"] }, + "handbrake_name": { "type": "string", "minLength": 1 }, + "purpose": { "type": "string", "minLength": 1 }, + "container": { "const": "mp4" }, + "whole_file_only": { "const": true } + } + } + } +} diff --git a/schemas/renderflow-handbrake-transform-v1.schema.json b/schemas/renderflow-handbrake-transform-v1.schema.json new file mode 100644 index 0000000..b3ab334 --- /dev/null +++ b/schemas/renderflow-handbrake-transform-v1.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-handbrake-transform-v1.schema.json", + "title": "Renderflow HandBrake transform documents v1", + "oneOf": [ + { "$ref": "#/$defs/plan" }, + { "$ref": "#/$defs/report" } + ], + "$defs": { + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "value"], + "properties": { + "algorithm": { "const": "sha256" }, + "value": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + }, + "preset": { + "type": "object", + "additionalProperties": false, + "required": ["id", "handbrake_name", "purpose", "container", "whole_file_only"], + "properties": { + "id": { "enum": ["fast_720p30", "fast_1080p30", "creator_1080p60", "production_standard"] }, + "handbrake_name": { "type": "string", "minLength": 1 }, + "purpose": { "type": "string", "minLength": 1 }, + "container": { "const": "mp4" }, + "whole_file_only": { "const": true } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": ["timeout_seconds", "capture_limit_bytes", "progress_interval_ms", "maximum_output_bytes"], + "properties": { + "timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 86400 }, + "capture_limit_bytes": { "type": "integer", "minimum": 4096, "maximum": 4194304 }, + "progress_interval_ms": { "type": "integer", "minimum": 100, "maximum": 60000 }, + "maximum_output_bytes": { "type": "integer", "minimum": 1, "maximum": 1099511627776 } + } + }, + "flowArtifact": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "artifact_id", "role", "media_type", "digest", "size_bytes", "producer", "sources"], + "properties": { + "schema_version": { "const": "flow.artifact/v1" }, + "artifact_id": { "type": "string", "pattern": "^artifact:sha256-[0-9a-f]{64}$" }, + "role": { "const": "video_delivery" }, + "media_type": { "const": "video/mp4" }, + "digest": { "$ref": "#/$defs/digest" }, + "size_bytes": { "type": "integer", "minimum": 1 }, + "producer": { + "type": "object", + "additionalProperties": false, + "required": ["owner", "capability_id", "provider_version"], + "properties": { + "owner": { "const": "renderflow" }, + "capability_id": { "const": "video.transcode.whole_file" }, + "provider_version": { "type": "string", "minLength": 1 } + } + }, + "sources": { + "type": "array", + "items": { "type": "string", "pattern": "^artifact:sha256-[0-9a-f]{64}$" }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "plan": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "capability_id", "provider_id", "tool_id", "temporal_scope", "input", "input_digest", "input_size_bytes", "output", "preset", "arguments", "limits"], + "properties": { + "schema_version": { "const": "renderflow.handbrake-transform/v1" }, + "capability_id": { "const": "video.transcode.whole_file" }, + "provider_id": { "const": "adapter.media.handbrake" }, + "tool_id": { "const": "tool.handbrake" }, + "temporal_scope": { "const": "whole_file" }, + "input": { "type": "string", "minLength": 1 }, + "input_digest": { "$ref": "#/$defs/digest" }, + "input_size_bytes": { "type": "integer", "minimum": 1 }, + "output": { "type": "string", "pattern": "\\.[mM][pP]4$" }, + "preset": { "$ref": "#/$defs/preset" }, + "arguments": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "limits": { "$ref": "#/$defs/limits" } + } + }, + "report": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "capability_id", "provider_id", "renderflow_version", "handbrake_version", "temporal_scope", "preset", "input", "input_digest", "input_size_bytes", "output", "provenance", "output_digest", "output_size_bytes", "argv_digest", "started_at_unix_ms", "completed_at_unix_ms", "duration_ms", "stdout_total_bytes", "stderr_total_bytes", "stdout_truncated", "stderr_truncated", "validation", "flow_artifact", "aniflow_segment_capability", "aniflow_reconstruct_capability"], + "properties": { + "schema_version": { "const": "renderflow.handbrake-transform/v1" }, + "capability_id": { "const": "video.transcode.whole_file" }, + "provider_id": { "const": "adapter.media.handbrake" }, + "renderflow_version": { "type": "string", "minLength": 1 }, + "handbrake_version": { "type": "string", "minLength": 1 }, + "temporal_scope": { "const": "whole_file" }, + "preset": { "$ref": "#/$defs/preset" }, + "input": { "type": "string", "minLength": 1 }, + "input_digest": { "$ref": "#/$defs/digest" }, + "input_size_bytes": { "type": "integer", "minimum": 1 }, + "output": { "type": "string", "pattern": "\\.[mM][pP]4$" }, + "provenance": { "type": "string", "pattern": "\\.[mM][pP]4\\.renderflow\\.json$" }, + "output_digest": { "$ref": "#/$defs/digest" }, + "output_size_bytes": { "type": "integer", "minimum": 1 }, + "argv_digest": { "$ref": "#/$defs/digest" }, + "started_at_unix_ms": { "type": "integer", "minimum": 0 }, + "completed_at_unix_ms": { "type": "integer", "minimum": 0 }, + "duration_ms": { "type": "integer", "minimum": 0 }, + "stdout_total_bytes": { "type": "integer", "minimum": 0 }, + "stderr_total_bytes": { "type": "integer", "minimum": 0 }, + "stdout_truncated": { "type": "boolean" }, + "stderr_truncated": { "type": "boolean" }, + "validation": { + "type": "object", + "additionalProperties": false, + "required": ["non_empty", "mp4_file_type_box", "within_output_limit"], + "properties": { + "non_empty": { "const": true }, + "mp4_file_type_box": { "const": true }, + "within_output_limit": { "const": true } + } + }, + "flow_artifact": { "$ref": "#/$defs/flowArtifact" }, + "aniflow_segment_capability": { "const": "media.video.segment/v1" }, + "aniflow_reconstruct_capability": { "const": "media.video.reconstruct/v1" } + } + } + } +}