diff --git a/exporter/integration_tests/src/runner.rs b/exporter/integration_tests/src/runner.rs index 3872309036a0..ef23dd210ca4 100644 --- a/exporter/integration_tests/src/runner.rs +++ b/exporter/integration_tests/src/runner.rs @@ -2,7 +2,7 @@ use anyhow::{anyhow, Context, Result}; use clap::Parser; -use exporter::{run_main, Opt}; +use exporter::{cli::Opt, run_main}; use libtest_mimic::Trial; use ruffle_fs_tests_runner::{FsTestsRunner, TestLoaderParams}; use serde::Deserialize; diff --git a/exporter/src/cli.rs b/exporter/src/cli.rs new file mode 100644 index 000000000000..bbc9ed0fc7eb --- /dev/null +++ b/exporter/src/cli.rs @@ -0,0 +1,115 @@ +use crate::player_ext::PlayerExporterExt; +use anyhow::Result; +use clap::Parser; +use ruffle_core::Player; +use ruffle_render_wgpu::clap::{GraphicsBackend, PowerPreference}; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; + +#[derive(Parser, Debug, Copy, Clone)] +pub struct SizeOpt { + /// The amount to scale the page size with + #[clap(long = "scale", default_value = "1.0")] + pub scale: f64, + + /// Optionally override the output width + #[clap(long = "width")] + pub width: Option, + + /// Optionally override the output height + #[clap(long = "height")] + pub height: Option, +} + +#[derive(Debug, Clone, Copy)] +pub enum FrameSelection { + All, + Count(NonZeroU32), +} + +impl FrameSelection { + pub fn is_single_frame(self) -> bool { + match self { + FrameSelection::All => false, + FrameSelection::Count(n) => n.get() == 1, + } + } + + pub fn total_frames(self, player: &Arc>, skipframes: u32) -> u32 { + match self { + // TODO Getting frame count from the header won't always work. + FrameSelection::All => player.header_frames() as u32, + FrameSelection::Count(n) => n.get() + skipframes, + } + } +} + +impl FromStr for FrameSelection { + type Err = String; + + fn from_str(s: &str) -> Result { + let s_lower = s.to_ascii_lowercase(); + if s_lower == "all" { + Ok(FrameSelection::All) + } else if let Ok(n) = s.parse::() { + let non_zero = NonZeroU32::new(n) + .ok_or_else(|| "Frame count must be greater than 0".to_string())?; + Ok(FrameSelection::Count(non_zero)) + } else { + Err(format!("Invalid value for --frames: {s}")) + } + } +} + +#[derive(Parser, Debug)] +#[clap(name = "Ruffle Exporter", author, version)] +pub struct Opt { + /// The file or directory of files to export frames from + #[clap(name = "swf")] + pub swf: PathBuf, + + /// The file or directory (if multiple frames/files) to store the capture in. + /// The default value will either be: + /// - If given one swf and one frame, the name of the swf + ".png" + /// - If given one swf and multiple frames, the name of the swf as a directory + /// - If given multiple swfs, this field is required. + #[clap(name = "output")] + pub output_path: Option, + + /// Number of frames to capture per file. Use 'all' to capture all frames. + #[clap(short = 'f', long = "frames", default_value = "1")] + pub frames: FrameSelection, + + /// Number of frames to skip + #[clap(long = "skipframes", default_value = "0")] + pub skipframes: u32, + + /// Don't show a progress bar + #[clap(short, long, action)] + pub silent: bool, + + #[clap(flatten)] + pub size: SizeOpt, + + /// Force the main timeline to play, bypassing "Click to Play" buttons and similar restrictions. + /// This can help automate playback in some SWFs, but may break or alter content that expects user interaction. + /// Use with caution: enabling this may cause some movies to behave incorrectly. + #[clap(long)] + pub force_play: bool, + + /// Type of graphics backend to use. Not all options may be supported by your current system. + /// Default will attempt to pick the most supported graphics backend. + #[clap(long, short, default_value = "default")] + pub graphics: GraphicsBackend, + + /// Power preference for the graphics device used. High power usage tends to prefer dedicated GPUs, + /// whereas a low power usage tends prefer integrated GPUs. + #[clap(long, short, default_value = "high")] + pub power: PowerPreference, + + /// TODO Unused, remove after some time + #[clap(long, action, hide = true)] + pub skip_unsupported: bool, +} diff --git a/exporter/src/exporter.rs b/exporter/src/exporter.rs new file mode 100644 index 000000000000..5701965e4ae1 --- /dev/null +++ b/exporter/src/exporter.rs @@ -0,0 +1,131 @@ +use std::panic::catch_unwind; +use std::path::Path; +use std::sync::Arc; +use std::sync::Mutex; + +use image::RgbaImage; +use ruffle_core::limits::ExecutionLimit; +use ruffle_core::tag_utils::SwfMovie; +use ruffle_core::Player; +use ruffle_core::PlayerBuilder; +use ruffle_render_wgpu::backend::WgpuRenderBackend; +use ruffle_render_wgpu::descriptors::Descriptors; + +use anyhow::anyhow; +use anyhow::Result; +use ruffle_render_wgpu::backend::request_adapter_and_device; +use ruffle_render_wgpu::target::TextureTarget; +use ruffle_render_wgpu::wgpu; + +use crate::cli::FrameSelection; +use crate::cli::Opt; +use crate::cli::SizeOpt; +use crate::player_ext::PlayerExporterExt; + +pub struct Exporter { + descriptors: Arc, + size: SizeOpt, + skipframes: u32, + frames: FrameSelection, + force_play: bool, +} + +impl Exporter { + pub fn new(opt: &Opt) -> Result { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: opt.graphics.into(), + ..Default::default() + }); + let (adapter, device, queue) = futures::executor::block_on(request_adapter_and_device( + opt.graphics.into(), + &instance, + None, + opt.power.into(), + )) + .map_err(|e| anyhow!(e.to_string()))?; + + let descriptors = Arc::new(Descriptors::new(instance, adapter, device, queue)); + + Ok(Self { + descriptors, + size: opt.size, + skipframes: opt.skipframes, + frames: opt.frames, + force_play: opt.force_play, + }) + } + + pub fn start_exporting_movie(&self, swf_path: &Path) -> Result { + let movie = SwfMovie::from_path(swf_path, None).map_err(|e| anyhow!(e.to_string()))?; + + let width = self + .size + .width + .map(f64::from) + .unwrap_or_else(|| movie.width().to_pixels()); + let width = (width * self.size.scale).round() as u32; + + let height = self + .size + .height + .map(f64::from) + .unwrap_or_else(|| movie.height().to_pixels()); + let height = (height * self.size.scale).round() as u32; + + let target = TextureTarget::new(&self.descriptors.device, (width, height)) + .map_err(|e| anyhow!(e.to_string()))?; + let player = PlayerBuilder::new() + .with_renderer( + WgpuRenderBackend::new(self.descriptors.clone(), target) + .map_err(|e| anyhow!(e.to_string()))?, + ) + .with_movie(movie) + .with_viewport_dimensions(width, height, self.size.scale) + .build(); + + Ok(MovieExport { + player, + skipframes: self.skipframes, + frames: self.frames, + force_play: self.force_play, + }) + } +} + +pub struct MovieExport { + player: Arc>, + skipframes: u32, + frames: FrameSelection, + force_play: bool, +} + +impl MovieExport { + pub fn total_frames(&self) -> u32 { + self.frames.total_frames(&self.player, self.skipframes) + } + + pub fn run_frame(&self) { + if self.force_play { + self.player.force_root_clip_play(); + } + + self.player + .lock() + .unwrap() + .preload(&mut ExecutionLimit::none()); + + self.player.lock().unwrap().run_frame(); + } + + pub fn capture_frame(&self) -> Result { + let image = || { + self.player.lock().unwrap().render(); + self.player.capture_frame() + }; + match catch_unwind(image) { + Ok(Some(image)) => Ok(image), + Ok(None) => Err(anyhow!("No frame captured")), + Err(e) => Err(anyhow!("{e:?}")), + } + } +} diff --git a/exporter/src/lib.rs b/exporter/src/lib.rs index bce13295f40a..d8d6ce0f148d 100644 --- a/exporter/src/lib.rs +++ b/exporter/src/lib.rs @@ -1,195 +1,46 @@ +pub mod cli; +mod exporter; mod player_ext; +mod progress; use anyhow::{anyhow, Result}; -use clap::Parser; use image::RgbaImage; -use indicatif::{ProgressBar, ProgressStyle}; -use player_ext::PlayerExporterExt; +use indicatif::ProgressBar; use rayon::prelude::*; -use ruffle_core::limits::ExecutionLimit; -use ruffle_core::tag_utils::SwfMovie; -use ruffle_core::{Player, PlayerBuilder}; -use ruffle_render_wgpu::backend::{request_adapter_and_device, WgpuRenderBackend}; -use ruffle_render_wgpu::clap::{GraphicsBackend, PowerPreference}; -use ruffle_render_wgpu::descriptors::Descriptors; -use ruffle_render_wgpu::target::TextureTarget; -use ruffle_render_wgpu::wgpu; use std::fs::create_dir_all; use std::io::{self, Write}; -use std::num::NonZeroU32; -use std::panic::catch_unwind; use std::path::{Path, PathBuf}; -use std::str::FromStr; -use std::sync::{Arc, Mutex}; use walkdir::{DirEntry, WalkDir}; -#[derive(Parser, Debug, Copy, Clone)] -pub struct SizeOpt { - /// The amount to scale the page size with - #[clap(long = "scale", default_value = "1.0")] - scale: f64, - - /// Optionally override the output width - #[clap(long = "width")] - width: Option, - - /// Optionally override the output height - #[clap(long = "height")] - height: Option, -} - -#[derive(Debug, Clone, Copy)] -enum FrameSelection { - All, - Count(NonZeroU32), -} - -impl FrameSelection { - fn is_single_frame(self) -> bool { - match self { - FrameSelection::All => false, - FrameSelection::Count(n) => n.get() == 1, - } - } - - fn total_frames(self, player: &Arc>, skipframes: u32) -> u32 { - match self { - // TODO Getting frame count from the header won't always work. - FrameSelection::All => player.header_frames() as u32, - FrameSelection::Count(n) => n.get() + skipframes, - } - } -} - -impl FromStr for FrameSelection { - type Err = String; - - fn from_str(s: &str) -> Result { - let s_lower = s.to_ascii_lowercase(); - if s_lower == "all" { - Ok(FrameSelection::All) - } else if let Ok(n) = s.parse::() { - let non_zero = NonZeroU32::new(n) - .ok_or_else(|| "Frame count must be greater than 0".to_string())?; - Ok(FrameSelection::Count(non_zero)) - } else { - Err(format!("Invalid value for --frames: {s}")) - } - } -} - -#[derive(Parser, Debug)] -#[clap(name = "Ruffle Exporter", author, version)] -pub struct Opt { - /// The file or directory of files to export frames from - #[clap(name = "swf")] - swf: PathBuf, - - /// The file or directory (if multiple frames/files) to store the capture in. - /// The default value will either be: - /// - If given one swf and one frame, the name of the swf + ".png" - /// - If given one swf and multiple frames, the name of the swf as a directory - /// - If given multiple swfs, this field is required. - #[clap(name = "output")] - output_path: Option, - - /// Number of frames to capture per file. Use 'all' to capture all frames. - #[clap(short = 'f', long = "frames", default_value = "1")] - frames: FrameSelection, - - /// Number of frames to skip - #[clap(long = "skipframes", default_value = "0")] - skipframes: u32, - - /// Don't show a progress bar - #[clap(short, long, action)] - silent: bool, - - #[clap(flatten)] - size: SizeOpt, - - /// Force the main timeline to play, bypassing "Click to Play" buttons and similar restrictions. - /// This can help automate playback in some SWFs, but may break or alter content that expects user interaction. - /// Use with caution: enabling this may cause some movies to behave incorrectly. - #[clap(long)] - force_play: bool, - - /// Type of graphics backend to use. Not all options may be supported by your current system. - /// Default will attempt to pick the most supported graphics backend. - #[clap(long, short, default_value = "default")] - graphics: GraphicsBackend, - - /// Power preference for the graphics device used. High power usage tends to prefer dedicated GPUs, - /// whereas a low power usage tends prefer integrated GPUs. - #[clap(long, short, default_value = "high")] - power: PowerPreference, - - /// TODO Unused, remove after some time - #[clap(long, action, hide = true)] - skip_unsupported: bool, -} +use crate::cli::{FrameSelection, Opt}; +use crate::exporter::Exporter; +use crate::progress::ExporterProgress; /// Captures a screenshot. The resulting image uses straight alpha fn take_screenshot( - descriptors: Arc, + exporter: &Exporter, swf_path: &Path, frames: FrameSelection, // TODO Figure out a way to get framecount before calling take_screenshot, so that we can have accurate progress bars when using --frames all skipframes: u32, - progress: &Option, - size: SizeOpt, - force_play: bool, + progress: &ExporterProgress, ) -> Result> { - let movie = SwfMovie::from_path(swf_path, None).map_err(|e| anyhow!(e.to_string()))?; - - let width = size - .width - .map(f64::from) - .unwrap_or_else(|| movie.width().to_pixels()); - let width = (width * size.scale).round() as u32; - - let height = size - .height - .map(f64::from) - .unwrap_or_else(|| movie.height().to_pixels()); - let height = (height * size.scale).round() as u32; - - let target = TextureTarget::new(&descriptors.device, (width, height)) - .map_err(|e| anyhow!(e.to_string()))?; - let player = PlayerBuilder::new() - .with_renderer( - WgpuRenderBackend::new(descriptors, target).map_err(|e| anyhow!(e.to_string()))?, - ) - .with_movie(movie) - .with_viewport_dimensions(width, height, size.scale) - .build(); + let movie_export = exporter.start_exporting_movie(swf_path)?; let mut result = Vec::new(); - let totalframes = frames.total_frames(&player, skipframes); + let totalframes = movie_export.total_frames(); for i in 0..totalframes { - if let Some(progress) = &progress { - progress.set_message(format!( - "{} frame {}", - swf_path.file_stem().unwrap().to_string_lossy(), - i - )); - } - - if force_play { - player.force_root_clip_play(); - } + progress.set_message(format!( + "{} frame {}", + swf_path.file_stem().unwrap().to_string_lossy(), + i + )); - player.lock().unwrap().preload(&mut ExecutionLimit::none()); + movie_export.run_frame(); - player.lock().unwrap().run_frame(); if i >= skipframes { - let image = || { - player.lock().unwrap().render(); - player.capture_frame() - }; - match catch_unwind(image) { - Ok(Some(image)) => result.push(image), - Ok(None) => return Err(anyhow!("Unable to capture frame {} of {:?}", i, swf_path)), + match movie_export.capture_frame() { + Ok(image) => result.push(image), Err(e) => { return Err(anyhow!( "Unable to capture frame {} of {:?}: {:?}", @@ -202,9 +53,7 @@ fn take_screenshot( } if !matches!(frames, FrameSelection::All) { - if let Some(progress) = &progress { - progress.inc(1); - } + progress.inc(1); } } Ok(result) @@ -240,7 +89,7 @@ fn find_files(root: &Path, with_progress: bool) -> Vec { results } -fn capture_single_swf(descriptors: Arc, opt: &Opt) -> Result<()> { +fn capture_single_swf(exporter: &Exporter, opt: &Opt) -> Result<()> { let is_single_frame = opt.frames.is_single_frame(); let output = opt.output_path.clone().unwrap_or_else(|| { let mut result = PathBuf::new(); @@ -255,36 +104,11 @@ fn capture_single_swf(descriptors: Arc, opt: &Opt) -> Result<()> { let _ = create_dir_all(&output); } - let progress = if !opt.silent { - let progress = match opt.frames { - FrameSelection::Count(n) => ProgressBar::new(n.get() as u64), - _ => ProgressBar::new_spinner(), // TODO Once we figure out a way to get framecount before calling take_screenshot, then this can be changed back to a progress bar when using --frames all - }; - progress.set_style( - ProgressStyle::with_template( - "[{elapsed_precise}] {bar:40.cyan/blue} [{eta_precise}] {pos:>7}/{len:7} {msg}", - ) - .unwrap() - .progress_chars("##-"), - ); - Some(progress) - } else { - None - }; + let progress = ExporterProgress::new(opt, 1); - let frames = take_screenshot( - descriptors, - &opt.swf, - opt.frames, - opt.skipframes, - &progress, - opt.size, - opt.force_play, - )?; + let frames = take_screenshot(exporter, &opt.swf, opt.frames, opt.skipframes, &progress)?; - if let Some(progress) = &progress { - progress.set_message(opt.swf.file_stem().unwrap().to_string_lossy().into_owned()); - } + progress.set_message(opt.swf.file_stem().unwrap().to_string_lossy().into_owned()); if is_single_frame { let image = frames.first().unwrap(); @@ -328,56 +152,29 @@ fn capture_single_swf(descriptors: Arc, opt: &Opt) -> Result<()> { }; if let Some(message) = message { - if let Some(progress) = progress { - progress.finish_with_message(message); - } else { - println!("{message}"); - } + progress.finish_with_message(message); } Ok(()) } -fn capture_multiple_swfs(descriptors: Arc, opt: &Opt) -> Result<()> { +fn capture_multiple_swfs(exporter: &Exporter, opt: &Opt) -> Result<()> { let output = opt.output_path.clone().unwrap(); let files = find_files(&opt.swf, !opt.silent); - let progress = if !opt.silent { - let progress = match opt.frames { - FrameSelection::Count(n) => ProgressBar::new((files.len() as u64) * (n.get() as u64)), - _ => ProgressBar::new(files.len() as u64), - }; - progress.set_style( - ProgressStyle::with_template( - "[{elapsed_precise}] {bar:40.cyan/blue} [{eta_precise}] {pos:>7}/{len:7} {msg}", - ) - .unwrap() - .progress_chars("##-"), - ); - Some(progress) - } else { - None - }; + let progress = ExporterProgress::new(opt, files.len() as u64); files.par_iter().try_for_each(|file| -> Result<()> { - if let Some(progress) = &progress { - progress.set_message( - file.path() - .file_stem() - .unwrap() - .to_string_lossy() - .into_owned(), - ); - } - if let Ok(frames) = take_screenshot( - descriptors.clone(), - file.path(), - opt.frames, - opt.skipframes, - &progress, - opt.size, - opt.force_play, - ) { + progress.set_message( + file.path() + .file_stem() + .unwrap() + .to_string_lossy() + .into_owned(), + ); + if let Ok(frames) = + take_screenshot(exporter, file.path(), opt.frames, opt.skipframes, &progress) + { let mut relative_path = file .path() .strip_prefix(&opt.swf) @@ -428,39 +225,23 @@ fn capture_multiple_swfs(descriptors: Arc, opt: &Opt) -> Result<()> ), }; - if let Some(progress) = progress { - progress.finish_with_message(message); - } else { - println!("{message}"); - } + progress.finish_with_message(message); Ok(()) } pub fn run_main(opt: Opt) -> Result<()> { - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { - backends: opt.graphics.into(), - ..Default::default() - }); - let (adapter, device, queue) = futures::executor::block_on(request_adapter_and_device( - opt.graphics.into(), - &instance, - None, - opt.power.into(), - )) - .map_err(|e| anyhow!(e.to_string()))?; - - let descriptors = Arc::new(Descriptors::new(instance, adapter, device, queue)); + let exporter = Exporter::new(&opt)?; if opt.swf.is_file() { - capture_single_swf(descriptors, &opt)?; + capture_single_swf(&exporter, &opt)?; } else if !opt.swf.is_dir() { return Err(anyhow!( "Not a file or directory: {}", opt.swf.to_string_lossy() )); } else if opt.output_path.is_some() { - capture_multiple_swfs(descriptors, &opt)?; + capture_multiple_swfs(&exporter, &opt)?; } else { return Err(anyhow!( "Output directory is required when exporting multiple files." diff --git a/exporter/src/main.rs b/exporter/src/main.rs index d5f72a3c5c9d..d6544c03992e 100644 --- a/exporter/src/main.rs +++ b/exporter/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use exporter::{run_main, Opt}; +use exporter::{cli::Opt, run_main}; fn main() -> Result<()> { let opt: Opt = Opt::parse(); diff --git a/exporter/src/progress.rs b/exporter/src/progress.rs new file mode 100644 index 000000000000..c842bf9d825c --- /dev/null +++ b/exporter/src/progress.rs @@ -0,0 +1,51 @@ +use std::borrow::Cow; + +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::cli::{FrameSelection, Opt}; + +pub struct ExporterProgress { + progress: Option, +} + +impl ExporterProgress { + pub fn new(opt: &Opt, files_count: u64) -> Self { + let progress = if !opt.silent { + let progress = match opt.frames { + FrameSelection::Count(n) => ProgressBar::new(files_count * (n.get() as u64)), + _ => ProgressBar::new_spinner(), // TODO Once we figure out a way to get framecount before calling take_screenshot, then this can be changed back to a progress bar when using --frames all + }; + progress.set_style( + ProgressStyle::with_template( + "[{elapsed_precise}] {bar:40.cyan/blue} [{eta_precise}] {pos:>7}/{len:7} {msg}", + ) + .unwrap() + .progress_chars("##-"), + ); + Some(progress) + } else { + None + }; + Self { progress } + } + + pub fn set_message(&self, msg: impl Into>) { + if let Some(progress) = &self.progress { + progress.set_message(msg); + } + } + + pub fn inc(&self, delta: u64) { + if let Some(progress) = &self.progress { + progress.inc(delta); + } + } + + pub fn finish_with_message(&self, msg: impl Into>) { + if let Some(progress) = &self.progress { + progress.finish_with_message(msg); + } else { + println!("{}", msg.into()); + } + } +}