From 40bac9a7ddf7da2b333b45bc66a63ddbb658ef0a Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 20 Apr 2026 11:24:30 +0900 Subject: [PATCH 01/27] rewrote phichain-renderer with direct RGBA pipe to ffmpeg --- Cargo.lock | 1 - phichain-renderer/Cargo.toml | 1 - phichain-renderer/src/capture.rs | 206 +++++++++++ phichain-renderer/src/encoder.rs | 170 +++++++++ phichain-renderer/src/main.rs | 567 ++++--------------------------- phichain-renderer/src/respack.rs | 25 ++ 6 files changed, 462 insertions(+), 508 deletions(-) create mode 100644 phichain-renderer/src/capture.rs create mode 100644 phichain-renderer/src/encoder.rs create mode 100644 phichain-renderer/src/respack.rs diff --git a/Cargo.lock b/Cargo.lock index 615f904e..0e7ab0b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6129,7 +6129,6 @@ dependencies = [ "bevy_kira_audio", "clap", "crossbeam-channel", - "image", "phichain-assets", "phichain-chart", "phichain-game", diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index 163fbb4f..c0b49f93 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -16,5 +16,4 @@ bevy = { workspace = true } bevy_kira_audio = { workspace = true } clap = { version = "4.5.15", features = ["derive"] } crossbeam-channel = "0.5.15" -image = "0.25.2" anyhow = "1.0.86" diff --git a/phichain-renderer/src/capture.rs b/phichain-renderer/src/capture.rs new file mode 100644 index 00000000..89d45f11 --- /dev/null +++ b/phichain-renderer/src/capture.rs @@ -0,0 +1,206 @@ +//! Offscreen capture pipeline. +//! +//! The camera renders into a GPU texture we own (not a window). Each frame +//! we copy that texture into a staging buffer that the CPU can map and read, +//! then ship the raw RGBA bytes back to the main world over a channel. + +use bevy::camera::RenderTarget; +use bevy::prelude::*; +use bevy::render::{ + render_asset::RenderAssets, + render_graph::{self, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel}, + render_resource::{ + Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, MapMode, + PollType, TexelCopyBufferInfo, TexelCopyBufferLayout, TextureFormat, TextureUsages, + }, + renderer::{RenderContext, RenderDevice, RenderQueue}, + texture::GpuImage, + Extract, Render, RenderApp, RenderSystems, +}; +use crossbeam_channel::{Receiver, Sender}; +use std::num::NonZero; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +/// Raw RGBA bytes (possibly row-padded — see [`unpad_rows`]) for each rendered frame. +#[derive(Resource, Deref)] +pub struct FrameReceiver(Receiver>); + +#[derive(Resource, Deref)] +struct FrameSender(Sender>); + +pub struct CapturePlugin; + +impl Plugin for CapturePlugin { + fn build(&self, app: &mut App) { + let (tx, rx) = crossbeam_channel::unbounded(); + app.insert_resource(FrameReceiver(rx)); + + let render_app = app.sub_app_mut(RenderApp); + render_app.insert_resource(FrameSender(tx)); + + // Run our copy node right after the camera finishes rendering. + let mut graph = render_app.world_mut().resource_mut::(); + graph.add_node(CaptureLabel, CaptureNode); + graph.add_node_edge(bevy::render::graph::CameraDriverLabel, CaptureLabel); + + render_app + .add_systems(ExtractSchedule, extract_copiers) + .add_systems(Render, send_frame.after(RenderSystems::Render)); + } +} + +/// Create an offscreen render target and the readback buffer for it. +/// +/// Spawns an `ImageCopier` entity the render graph will pick up via extract. +/// The returned `RenderTarget` should be attached to a `Camera2d`. +pub fn setup_offscreen_target( + commands: &mut Commands, + images: &mut Assets, + render_device: &RenderDevice, + width: u32, + height: u32, +) -> RenderTarget { + let size = Extent3d { + width, + height, + ..default() + }; + + // GPU-only texture the camera renders into. COPY_SRC lets us read it back. + let mut image = Image::new_target_texture(width, height, TextureFormat::Rgba8UnormSrgb, None); + image.texture_descriptor.usage |= TextureUsages::COPY_SRC; + let handle = images.add(image); + + commands.spawn(ImageCopier::new(handle.clone(), size, render_device)); + RenderTarget::Image(handle.into()) +} + +/// Strip the per-row padding from a mapped staging buffer to get tight +/// `width * height * 4` RGBA bytes. wgpu requires each row copied into a +/// buffer to be aligned to 256 bytes, so the buffer may be wider than the image. +pub fn unpad_rows(bytes: &[u8], width: u32, height: u32) -> Vec { + let row = width as usize * 4; + let padded = RenderDevice::align_copy_bytes_per_row(row); + if row == padded { + return bytes.to_vec(); + } + let mut out = Vec::with_capacity(row * height as usize); + for chunk in bytes.chunks_exact(padded).take(height as usize) { + out.extend_from_slice(&chunk[..row]); + } + out +} + +/// Marks a GPU image that should be copied out every frame. +#[derive(Component, Clone)] +struct ImageCopier { + buffer: Buffer, + src: Handle, + enabled: Arc, +} + +impl ImageCopier { + fn new(src: Handle, size: Extent3d, render_device: &RenderDevice) -> Self { + let padded_row = RenderDevice::align_copy_bytes_per_row(size.width as usize) * 4; + let buffer = render_device.create_buffer(&BufferDescriptor { + label: Some("phichain_readback_buffer"), + size: padded_row as u64 * size.height as u64, + usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Self { + buffer, + src, + enabled: Arc::new(AtomicBool::new(true)), + } + } +} + +#[derive(Resource, Default, Deref)] +struct ExtractedCopiers(Vec); + +fn extract_copiers(mut commands: Commands, q: Extract>) { + commands.insert_resource(ExtractedCopiers(q.iter().cloned().collect())); +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash, RenderLabel)] +struct CaptureLabel; + +struct CaptureNode; + +impl render_graph::Node for CaptureNode { + fn run( + &self, + _graph: &mut RenderGraphContext, + ctx: &mut RenderContext, + world: &World, + ) -> Result<(), NodeRunError> { + let Some(copiers) = world.get_resource::() else { + return Ok(()); + }; + let gpu_images = world.resource::>(); + let queue = world.resource::(); + + for copier in copiers.iter() { + if !copier.enabled.load(Ordering::Relaxed) { + continue; + } + let Some(src) = gpu_images.get(&copier.src) else { + continue; + }; + + let padded_row = + RenderDevice::align_copy_bytes_per_row(src.size.width as usize * 4) as u32; + + let mut encoder = ctx + .render_device() + .create_command_encoder(&CommandEncoderDescriptor::default()); + encoder.copy_texture_to_buffer( + src.texture.as_image_copy(), + TexelCopyBufferInfo { + buffer: &copier.buffer, + layout: TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(NonZero::new(padded_row).unwrap().into()), + rows_per_image: None, + }, + }, + src.size, + ); + queue.submit(std::iter::once(encoder.finish())); + } + + Ok(()) + } +} + +/// After the render graph runs, map the staging buffer and ship bytes back. +/// Blocks until the GPU finishes the copy (`poll` is mandatory on native). +fn send_frame( + copiers: Res, + render_device: Res, + sender: Res, +) { + for copier in copiers.iter() { + if !copier.enabled.load(Ordering::Relaxed) { + continue; + } + let slice = copier.buffer.slice(..); + + // map_async completes on the GPU's schedule; use a tiny channel to wait. + let (done_tx, done_rx) = crossbeam_channel::bounded(1); + slice.map_async(MapMode::Read, move |r| { + r.expect("buffer map failed"); + done_tx.send(()).unwrap(); + }); + render_device + .poll(PollType::wait_indefinitely()) + .expect("device poll"); + done_rx.recv().expect("map_async completion"); + + // Main world may have dropped its receiver on app exit — ignore errors. + let _ = sender.send(slice.get_mapped_range().to_vec()); + copier.buffer.unmap(); + } +} diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs new file mode 100644 index 00000000..3399b1c2 --- /dev/null +++ b/phichain-renderer/src/encoder.rs @@ -0,0 +1,170 @@ +//! ffmpeg subprocess management and per-frame writing. +//! +//! A child `ffmpeg` reads raw RGBA bytes from stdin at a fixed size/framerate +//! and produces an h264 mp4. We drive `ChartTime` one video-frame at a time; +//! the game code renders a matching frame; we pipe the captured bytes to ffmpeg. + +use bevy::app::AppExit; +use bevy::prelude::*; +use phichain_game::ChartTime; +use std::collections::VecDeque; +use std::io::Write; +use std::process::{Child, Command, Stdio}; +use std::time::Instant; + +use crate::args::Args; +use crate::capture::{unpad_rows, FrameReceiver}; + +/// Frames rendered before we start writing, giving the GPU time to warm up +/// (shader compilation, first-frame cache misses). The earliest frames are +/// often transparent or blocky and would show up as garbage. +const WARMUP_FRAMES: u32 = 40; + +pub struct EncoderPlugin; + +impl Plugin for EncoderPlugin { + fn build(&self, app: &mut App) { + app.add_systems(PostUpdate, write_frame); + } +} + +#[derive(Resource)] +pub struct Encoder { + ffmpeg: Child, + width: u32, + height: u32, + fps: u32, + from: f32, + to: f32, + + warmup_remaining: u32, + frames_written: u32, + total_frames: u32, + + start: Instant, + frame_times: VecDeque, + last_log_second: u32, + last_fps: usize, +} + +impl Encoder { + /// Spawn ffmpeg and return the encoder state. `from`/`to` bound the chart + /// time window to render (seconds). + pub fn spawn(args: &Args, from: f32, to: f32) -> Self { + let (width, height, fps) = (args.video.width, args.video.height, args.video.fps); + let total_frames = (fps as f32 * (to - from)) as u32; + + let ffmpeg = Command::new("ffmpeg") + .args(["-y", "-f", "rawvideo", "-pix_fmt", "rgba"]) + .args(["-s", &format!("{width}x{height}")]) + .args(["-framerate", &fps.to_string()]) + .args(["-an", "-i", "-"]) + .args(["-c:v", "libx264"]) + .arg(&args.output) + .stdin(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn ffmpeg (is it on PATH?)"); + + Self { + ffmpeg, + width, + height, + fps, + from, + to, + warmup_remaining: WARMUP_FRAMES, + frames_written: 0, + total_frames, + start: Instant::now(), + frame_times: VecDeque::new(), + last_log_second: 0, + last_fps: 0, + } + } + + fn next_chart_time(&self) -> f32 { + self.from + self.frames_written as f32 / self.fps as f32 + } + + fn done(&self) -> bool { + self.next_chart_time() >= self.to + } +} + +fn write_frame( + mut enc: ResMut, + mut chart_time: ResMut, + receiver: Res, + mut exit: MessageWriter, +) { + // Advance the chart one video-frame at a time. + chart_time.0 = enc.next_chart_time(); + + // Render world may enqueue multiple frames per tick if the GPU is fast; + // only the most recent one matches current state. + let Some(bytes) = receiver.try_iter().last() else { + return; + }; + + if enc.warmup_remaining > 0 { + enc.warmup_remaining -= 1; + return; + } + + let (width, height) = (enc.width, enc.height); + let pixels = unpad_rows(&bytes, width, height); + let stdin = enc + .ffmpeg + .stdin + .as_mut() + .expect("ffmpeg stdin was closed early"); + stdin + .write_all(&pixels) + .expect("failed to write frame to ffmpeg"); + + enc.frames_written += 1; + log_progress(&mut enc); + + if enc.done() { + // Closing stdin signals EOF; ffmpeg finalizes the file on its own. + drop(enc.ffmpeg.stdin.take()); + enc.ffmpeg + .wait() + .expect("ffmpeg exited with a non-zero status"); + exit.write(AppExit::Success); + } +} + +fn log_progress(enc: &mut Encoder) { + let elapsed = enc.start.elapsed().as_secs_f32(); + let second = elapsed as u32; + + // Sliding 1-second window of frame timestamps. + enc.frame_times.push_back(elapsed); + while enc.frame_times.front().is_some_and(|t| elapsed - *t > 1.0) { + enc.frame_times.pop_front(); + } + if enc.last_log_second != second { + enc.last_fps = enc.frame_times.len(); + enc.last_log_second = second; + } + + if !enc.frames_written.is_multiple_of(100) { + return; + } + let eta = if enc.last_fps == 0 { + f32::INFINITY + } else { + enc.total_frames.saturating_sub(enc.frames_written) as f32 / enc.last_fps as f32 + }; + info!( + "{} / {} ({:.1}%), {} fps ({:.2}x), eta {:.1}s", + enc.frames_written, + enc.total_frames, + enc.frames_written as f32 / enc.total_frames.max(1) as f32 * 100.0, + enc.last_fps, + enc.last_fps as f32 / enc.fps as f32, + eta, + ); +} diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index a38485af..a95bba83 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -1,61 +1,48 @@ -//! Reference: https://github.com/bevyengine/bevy/blob/main/examples/app/headless_renderer.rs +//! Phichain offscreen video renderer. +//! +//! Pipeline: +//! 1. Run the game logic headlessly (no window) with `ChartTime` stepped +//! one video-frame at a time. +//! 2. A 2D camera renders each frame into an offscreen GPU texture. +//! 3. `CapturePlugin` copies that texture back to the CPU and pushes raw +//! RGBA bytes through a channel. +//! 4. `EncoderPlugin` feeds those bytes into an ffmpeg subprocess, which +//! encodes the mp4 on the side. mod args; +mod capture; +mod encoder; +mod respack; mod utils; use crate::args::Args; -use bevy::app::{AppExit, ScheduleRunnerPlugin}; -use bevy::camera::RenderTarget; +use crate::capture::{setup_offscreen_target, CapturePlugin}; +use crate::encoder::{Encoder, EncoderPlugin}; +use crate::respack::RespackPlugin; +use bevy::app::ScheduleRunnerPlugin; use bevy::core_pipeline::tonemapping::Tonemapping; -use bevy::image::TextureFormatPixelInfo; use bevy::log::LogPlugin; use bevy::prelude::*; -use bevy::render::render_asset::RenderAssets; -use bevy::render::render_graph::{ - self, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel, -}; -use bevy::render::render_resource::{ - Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, MapMode, PollType, - TexelCopyBufferInfo, TexelCopyBufferLayout, TextureFormat, TextureUsages, -}; -use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue}; -use bevy::render::{Extract, Render, RenderApp, RenderSystems}; +use bevy::render::renderer::RenderDevice; use bevy::window::ExitCondition; use bevy::winit::WinitPlugin; use bevy_kira_audio::AudioPlugin; use clap::Parser; -use crossbeam_channel::{Receiver, Sender}; -use phichain_assets::{apply_respack, load_respack, AssetsPlugin}; +use phichain_assets::AssetsPlugin; use phichain_chart::project::Project; -use phichain_game::{ChartTime, GameConfig, GamePlugin, GameSet, GameViewport, Paused}; -use std::collections::VecDeque; -use std::io::Write; -use std::ops::DerefMut; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use phichain_game::{GameConfig, GamePlugin, GameSet, GameViewport, Paused}; use std::time::{Duration, Instant}; -/// This will receive asynchronously any data sent from the render world -#[derive(Resource, Deref)] -struct MainWorldReceiver(Receiver>); - -/// This will send asynchronously any data to the main world -#[derive(Resource, Deref)] -struct RenderWorldSender(Sender>); - fn main() { phichain_assets::setup_assets(); let args = Args::parse(); - - let start = Instant::now(); + let started = Instant::now(); App::new() .configure_sets(Update, GameSet) - .insert_resource(SceneController::new(args.video.width, args.video.height)) - .insert_resource(args) .insert_resource(ClearColor(Color::srgb_u8(0, 0, 0))) + .insert_resource(args) .add_plugins( DefaultPlugins .set(ImagePlugin::default_nearest()) @@ -72,504 +59,72 @@ fn main() { // WinitPlugin will panic in environments without a display server. .disable::(), ) - .add_plugins(ImageCopyPlugin) - // headless frame capture - .add_plugins(CaptureFramePlugin) + // Offline rendering: run the loop as fast as possible. .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::ZERO)) - .init_resource::() .add_plugins(AudioPlugin) .add_plugins(AssetsPlugin) - .add_plugins(CustomRespackPlugin) + .add_plugins(RespackPlugin) .add_plugins(GamePlugin) - .add_systems(Startup, setup_system) + .add_plugins(CapturePlugin) + .add_plugins(EncoderPlugin) + .add_systems(Startup, setup) .run(); info!( - "Render completed, elapsed: {:.2}s", - start.elapsed().as_secs_f64() + "render completed in {:.2}s", + started.elapsed().as_secs_f64() ); } -/// Capture image settings and state -#[derive(Debug, Default, Resource)] -struct SceneController { - state: SceneState, - width: u32, - height: u32, -} - -impl SceneController { - pub fn new(width: u32, height: u32) -> SceneController { - SceneController { - state: SceneState::BuildScene, - width, - height, - } - } -} - -/// Capture image state -#[derive(Debug, Default)] -enum SceneState { - #[default] - // State before any rendering - BuildScene, - // Rendering state, stores the number of frames remaining before saving the image - Render(u32), -} - -#[derive(Debug, Resource)] -struct FFmpeg(Child); - -#[derive(Debug, Resource)] -struct AppState { - start_time: Instant, - duration: f32, -} - -fn setup_system( +fn setup( mut commands: Commands, mut images: ResMut>, - mut scene_controller: ResMut, + mut viewport: ResMut, + mut paused: ResMut, + mut game_config: ResMut, render_device: Res, args: Res, ) { - let project = Project::open(args.path.clone().into()).expect("Failed to load project"); - - let duration = utils::audio_duration(project.path.music_path().unwrap()) - .expect("Failed to get audio duration"); - - commands.insert_resource(AppState { - start_time: Instant::now(), - duration, - }); - - let ffmpeg = Command::new("ffmpeg") - .arg("-y") - .arg("-framerate") - .arg(args.video.fps.to_string()) - .arg("-f") - .arg("rawvideo") - .arg("-pix_fmt") - .arg("rgba") - .arg("-s") - .arg(format!("{}x{}", args.video.width, args.video.height)) - // don't expect any audio in the stream - .arg("-an") - // get the data from stdin - .arg("-i") - .arg("-") - // encode to h264 - .arg("-c:v") - .arg("libx264") - .arg(args.output.clone()) - .stdin(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("Failed to spawn ffmpeg"); - commands.insert_resource(FFmpeg(ffmpeg)); - - let render_target = setup_render_target( + let project = Project::open(args.path.clone().into()).expect("failed to open project"); + let music_duration = utils::audio_duration( + project + .path + .music_path() + .expect("project is missing its music file"), + ) + .expect("failed to read audio duration"); + + let target = setup_offscreen_target( &mut commands, &mut images, &render_device, - &mut scene_controller, - // pre_roll_frames should be big enough for full scene render, - // but the bigger it is, the longer example will run. - // To visualize stages of scene rendering change this param to 0 - // and change AppConfig::single_image to false in main - // Stages are: - // 1. Transparent image - // 2. Few black box images - // 3. Fully rendered scene images - // Exact number depends on device speed, device load and scene size - 40, + args.video.width, + args.video.height, ); - let name = project.meta.name.clone(); - let level = project.meta.level.clone(); - - let width = args.video.width; - let height = args.video.height; - - let args = args.clone(); - commands.queue(move |world: &mut World| { - let mut viewport = world.resource_mut::(); - viewport.0 = Rect::from_corners(Vec2::ZERO, Vec2::new(width as f32, height as f32)); - let mut paused = world.resource_mut::(); - paused.0 = false; - let mut config = world.resource_mut::(); - - *config = args.game.into_game_config(name, level); - }); - commands.spawn(( Camera2d, - render_target, + target, + // The render target is already sRGB; writing values as-is matches the + // in-editor preview. Tonemapping::None, IsDefaultUiCamera, )); - phichain_game::loader::load_project(&project, &mut commands) - .expect("Failed to load project into the world"); -} - -pub struct CustomRespackPlugin; -impl Plugin for CustomRespackPlugin { - fn build(&self, app: &mut App) { - let Some(path) = app.world().resource::().respack.clone() else { - return; - }; - let pack = match load_respack(&path) { - Ok(pack) => pack, - Err(err) => { - eprintln!("error: failed to load respack {}: {err:#}", path.display()); - std::process::exit(1); - } - }; - if let Err(err) = apply_respack(pack, app.world_mut()) { - eprintln!("error: failed to apply respack {}: {err:#}", path.display()); - std::process::exit(1); - } - info!("loaded custom respack: {}", path.display()); - } -} - -/// Plugin for Render world part of work -pub struct ImageCopyPlugin; -impl Plugin for ImageCopyPlugin { - fn build(&self, app: &mut App) { - let (s, r) = crossbeam_channel::unbounded(); - - let render_app = app - .insert_resource(MainWorldReceiver(r)) - .sub_app_mut(RenderApp); - - let mut graph = render_app.world_mut().resource_mut::(); - graph.add_node(ImageCopy, ImageCopyDriver); - graph.add_node_edge(bevy::render::graph::CameraDriverLabel, ImageCopy); - - render_app - .insert_resource(RenderWorldSender(s)) - // Make ImageCopiers accessible in RenderWorld system and plugin - .add_systems(ExtractSchedule, image_copy_extract_system) - // Receives image data from buffer to channel - // so we need to run it after the render graph is done - .add_systems( - Render, - receive_image_from_buffer_system.after(RenderSystems::Render), - ); - } -} - -/// Setups render target and cpu image for saving, changes scene state into render mode -fn setup_render_target( - commands: &mut Commands, - images: &mut ResMut>, - render_device: &Res, - scene_controller: &mut ResMut, - pre_roll_frames: u32, -) -> RenderTarget { - let size = Extent3d { - width: scene_controller.width, - height: scene_controller.height, - ..Default::default() - }; - - // This is the texture that will be rendered to. - let mut render_target_image = - Image::new_target_texture(size.width, size.height, TextureFormat::bevy_default(), None); - render_target_image.texture_descriptor.usage |= TextureUsages::COPY_SRC; - let render_target_image_handle = images.add(render_target_image); - - // This is the texture that will be copied to. - let cpu_image = - Image::new_target_texture(size.width, size.height, TextureFormat::bevy_default(), None); - let cpu_image_handle = images.add(cpu_image); - - commands.spawn(ImageCopier::new( - render_target_image_handle.clone(), - size, - render_device, - )); - - commands.spawn(ImageToSave(cpu_image_handle)); - - scene_controller.state = SceneState::Render(pre_roll_frames); - RenderTarget::Image(render_target_image_handle.into()) -} - -/// Setups image saver -pub struct CaptureFramePlugin; -impl Plugin for CaptureFramePlugin { - fn build(&self, app: &mut App) { - app.add_systems(PostUpdate, update_system); - } -} - -/// `ImageCopier` aggregator in `RenderWorld` -#[derive(Clone, Default, Resource, Deref, DerefMut)] -struct ImageCopiers(pub Vec); - -/// Used by `ImageCopyDriver` for copying from render target to buffer -#[derive(Clone, Component)] -struct ImageCopier { - buffer: Buffer, - enabled: Arc, - src_image: Handle, -} - -impl ImageCopier { - pub fn new( - src_image: Handle, - size: Extent3d, - render_device: &RenderDevice, - ) -> ImageCopier { - let padded_bytes_per_row = - RenderDevice::align_copy_bytes_per_row((size.width) as usize) * 4; - - let cpu_buffer = render_device.create_buffer(&BufferDescriptor { - label: None, - size: padded_bytes_per_row as u64 * size.height as u64, - usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - ImageCopier { - buffer: cpu_buffer, - src_image, - enabled: Arc::new(AtomicBool::new(true)), - } - } - - pub fn enabled(&self) -> bool { - self.enabled.load(Ordering::Relaxed) - } -} - -/// Extracting `ImageCopier`s into render world, because `ImageCopyDriver` accesses them -fn image_copy_extract_system(mut commands: Commands, image_copiers: Extract>) { - commands.insert_resource(ImageCopiers( - image_copiers.iter().cloned().collect::>(), - )); -} - -/// `RenderGraph` label for `ImageCopyDriver` -#[derive(Debug, PartialEq, Eq, Clone, Hash, RenderLabel)] -struct ImageCopy; - -/// `RenderGraph` node -#[derive(Default)] -struct ImageCopyDriver; - -// Copies image content from render target to buffer -impl render_graph::Node for ImageCopyDriver { - fn run( - &self, - _graph: &mut RenderGraphContext, - render_context: &mut RenderContext, - world: &World, - ) -> Result<(), NodeRunError> { - let image_copiers = world.get_resource::().unwrap(); - let gpu_images = world - .get_resource::>() - .unwrap(); - - for image_copier in image_copiers.iter() { - if !image_copier.enabled() { - continue; - } - - let src_image = gpu_images.get(&image_copier.src_image).unwrap(); - - let mut encoder = render_context - .render_device() - .create_command_encoder(&CommandEncoderDescriptor::default()); - - let block_dimensions = src_image.texture_format.block_dimensions(); - let block_size = src_image.texture_format.block_copy_size(None).unwrap(); - - // Calculating correct size of image row because - // copy_texture_to_buffer can copy image only by rows aligned wgpu::COPY_BYTES_PER_ROW_ALIGNMENT - // That's why image in buffer can be little bit wider - // This should be taken into account at copy from buffer stage - let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row( - (src_image.size.width as usize / block_dimensions.0 as usize) * block_size as usize, - ); - - encoder.copy_texture_to_buffer( - src_image.texture.as_image_copy(), - TexelCopyBufferInfo { - buffer: &image_copier.buffer, - layout: TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some( - std::num::NonZero::::new(padded_bytes_per_row as u32) - .unwrap() - .into(), - ), - rows_per_image: None, - }, - }, - src_image.size, - ); - - let render_queue = world.get_resource::().unwrap(); - render_queue.submit(std::iter::once(encoder.finish())); - } - - Ok(()) - } -} - -/// runs in render world after Render stage to send image from buffer via channel (receiver is in main world) -fn receive_image_from_buffer_system( - image_copiers: Res, - render_device: Res, - sender: Res, -) { - for image_copier in image_copiers.0.iter() { - if !image_copier.enabled() { - continue; - } - - let buffer_slice = image_copier.buffer.slice(..); - - let (s, r) = crossbeam_channel::bounded(1); - - // Maps the buffer so it can be read on the cpu - buffer_slice.map_async(MapMode::Read, move |r| match r { - Ok(r) => s.send(r).expect("Failed to send map update"), - Err(err) => panic!("Failed to map buffer {err}"), - }); - - // This blocks until the gpu is done executing everything - render_device - .poll(PollType::wait_indefinitely()) - .expect("Failed to poll device for map async"); - - // This blocks until the buffer is mapped - r.recv().expect("Failed to receive the map_async message"); - - // This could fail on app exit, if Main world clears resources (including receiver) while Render world still renders - let _ = sender.send(buffer_slice.get_mapped_range().to_vec()); - - // Unmap so that we can copy to the staging buffer in the next iteration. - image_copier.buffer.unmap(); - } -} - -/// CPU-side image for saving -#[derive(Component, Deref, DerefMut)] -struct ImageToSave(Handle); - -// Takes from channel image content sent from render world and saves it to disk -fn update_system( - images_to_save: Query<&ImageToSave>, - receiver: Res, - mut images: ResMut>, - mut scene_controller: ResMut, - mut app_exit_writer: MessageWriter, - mut frame: Local, - mut chart_time: ResMut, - - mut ffmpeg: ResMut, - args: Res, - - // fps calculation, reference: https://github.com/TeamFlos/phira-render/blob/main/src-tauri/src/task.rs#L118 - mut frame_times: Local>, - mut last_update_fps_sec: Local, - mut last_fps: Local, + // Stand in for the main-window surrogate values the game code reads. + viewport.0 = Rect::from_corners( + Vec2::ZERO, + Vec2::new(args.video.width as f32, args.video.height as f32), + ); + paused.0 = false; + *game_config = args + .game + .clone() + .into_game_config(project.meta.name.clone(), project.meta.level.clone()); - state: Res, -) { let from = args.from.unwrap_or(0.0); - let to = args.to.unwrap_or(state.duration); - chart_time.0 = from + *frame as f32 / args.video.fps as f32; - let total_frames = (args.video.fps as f32 * (to - from)) as u32; - let estimate = total_frames.saturating_sub(*frame).max(1) as f32 / *last_fps as f32; - if (*frame).is_multiple_of(100) && *frame != 0 { - info!( - "{} / {} ({:.2}%), {}fps ({:.2}x), estimate to end {:.2}s", - *frame, - total_frames, - *frame as f32 / total_frames as f32 * 100.0, - *last_fps, - *last_fps as f32 / args.video.fps as f32, - estimate, - ); - } - if let SceneState::Render(n) = scene_controller.state { - if n < 1 { - // We don't want to block the main world on this, - // so we use try_recv which attempts to receive without blocking - let mut image_data = Vec::new(); - while let Ok(data) = receiver.try_recv() { - // image generation could be faster than saving to fs, - // that's why use only last of them - image_data = data; - } - if !image_data.is_empty() { - for image in images_to_save.iter() { - // Fill correct data from channel to image - let img_bytes = images.get_mut(image.id()).unwrap(); - - // We need to ensure that this works regardless of the image dimensions - // If the image became wider when copying from the texture to the buffer, - // then the data is reduced to its original size when copying from the buffer to the image. - let row_bytes = img_bytes.width() as usize - * img_bytes.texture_descriptor.format.pixel_size().unwrap(); - let aligned_row_bytes = RenderDevice::align_copy_bytes_per_row(row_bytes); - if row_bytes == aligned_row_bytes { - img_bytes.data.as_mut().unwrap().clone_from(&image_data); - } else { - // shrink data to original image size - img_bytes.data = Some( - image_data - .chunks(aligned_row_bytes) - .take(img_bytes.height() as usize) - .flat_map(|row| &row[..row_bytes.min(row.len())]) - .cloned() - .collect(), - ); - } - - // Create RGBA Image Buffer - let img = match img_bytes.clone().try_into_dynamic() { - Ok(img) => img.to_rgba8(), - Err(e) => panic!("Failed to create image buffer {e:?}"), - }; - - *frame.deref_mut() += 1; - - ffmpeg - .0 - .stdin - .as_mut() - .expect("Failed to get ffmpeg stdin") - .write_all(img.into_raw().as_ref()) - .expect("Failed to write to ffmpeg stdin"); + let to = args.to.unwrap_or(music_duration); + commands.insert_resource(Encoder::spawn(&args, from, to)); - let current = state.start_time.elapsed().as_secs_f32(); - let second = current as u32; - frame_times.push_back(current); - while frame_times.front().is_some_and(|x| current - *x > 1.) { - frame_times.pop_front(); - } - if *last_update_fps_sec != second { - *last_fps = frame_times.len(); - *last_update_fps_sec = second; - } - } - if chart_time.0 >= to { - app_exit_writer.write(AppExit::Success); - ffmpeg.0.wait().expect("Failed to wait ffmpeg"); - } - } - } else { - // clears channel for skipped frames - while receiver.try_recv().is_ok() {} - scene_controller.state = SceneState::Render(n - 1); - } - } + phichain_game::loader::load_project(&project, &mut commands).expect("failed to load project"); } diff --git a/phichain-renderer/src/respack.rs b/phichain-renderer/src/respack.rs new file mode 100644 index 00000000..4c56a3c3 --- /dev/null +++ b/phichain-renderer/src/respack.rs @@ -0,0 +1,25 @@ +//! Optional custom resource pack via `--respack`. + +use bevy::prelude::*; +use phichain_assets::{apply_respack, load_respack}; + +use crate::args::Args; + +pub struct RespackPlugin; + +impl Plugin for RespackPlugin { + fn build(&self, app: &mut App) { + let Some(path) = app.world().resource::().respack.clone() else { + return; + }; + let pack = load_respack(&path).unwrap_or_else(|err| { + eprintln!("error: failed to load respack {}: {err:#}", path.display()); + std::process::exit(1); + }); + if let Err(err) = apply_respack(pack, app.world_mut()) { + eprintln!("error: failed to apply respack {}: {err:#}", path.display()); + std::process::exit(1); + } + info!("loaded custom respack: {}", path.display()); + } +} From 403a9d17e43859aed5ac8a0788e4a2c4b1472da3 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 20 Apr 2026 13:05:53 +0900 Subject: [PATCH 02/27] simplified phichain-renderer with bevy's built-in Readback --- Cargo.lock | 1 - phichain-renderer/Cargo.toml | 1 - phichain-renderer/src/capture.rs | 206 ------------------------------- phichain-renderer/src/encoder.rs | 48 +++---- phichain-renderer/src/main.rs | 47 +++---- 5 files changed, 51 insertions(+), 252 deletions(-) delete mode 100644 phichain-renderer/src/capture.rs diff --git a/Cargo.lock b/Cargo.lock index 0e7ab0b1..75b79bc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6128,7 +6128,6 @@ dependencies = [ "bevy", "bevy_kira_audio", "clap", - "crossbeam-channel", "phichain-assets", "phichain-chart", "phichain-game", diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index c0b49f93..c586bf6e 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -15,5 +15,4 @@ phichain-assets = { path = "../phichain-assets" } bevy = { workspace = true } bevy_kira_audio = { workspace = true } clap = { version = "4.5.15", features = ["derive"] } -crossbeam-channel = "0.5.15" anyhow = "1.0.86" diff --git a/phichain-renderer/src/capture.rs b/phichain-renderer/src/capture.rs deleted file mode 100644 index 89d45f11..00000000 --- a/phichain-renderer/src/capture.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Offscreen capture pipeline. -//! -//! The camera renders into a GPU texture we own (not a window). Each frame -//! we copy that texture into a staging buffer that the CPU can map and read, -//! then ship the raw RGBA bytes back to the main world over a channel. - -use bevy::camera::RenderTarget; -use bevy::prelude::*; -use bevy::render::{ - render_asset::RenderAssets, - render_graph::{self, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel}, - render_resource::{ - Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, MapMode, - PollType, TexelCopyBufferInfo, TexelCopyBufferLayout, TextureFormat, TextureUsages, - }, - renderer::{RenderContext, RenderDevice, RenderQueue}, - texture::GpuImage, - Extract, Render, RenderApp, RenderSystems, -}; -use crossbeam_channel::{Receiver, Sender}; -use std::num::NonZero; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -/// Raw RGBA bytes (possibly row-padded — see [`unpad_rows`]) for each rendered frame. -#[derive(Resource, Deref)] -pub struct FrameReceiver(Receiver>); - -#[derive(Resource, Deref)] -struct FrameSender(Sender>); - -pub struct CapturePlugin; - -impl Plugin for CapturePlugin { - fn build(&self, app: &mut App) { - let (tx, rx) = crossbeam_channel::unbounded(); - app.insert_resource(FrameReceiver(rx)); - - let render_app = app.sub_app_mut(RenderApp); - render_app.insert_resource(FrameSender(tx)); - - // Run our copy node right after the camera finishes rendering. - let mut graph = render_app.world_mut().resource_mut::(); - graph.add_node(CaptureLabel, CaptureNode); - graph.add_node_edge(bevy::render::graph::CameraDriverLabel, CaptureLabel); - - render_app - .add_systems(ExtractSchedule, extract_copiers) - .add_systems(Render, send_frame.after(RenderSystems::Render)); - } -} - -/// Create an offscreen render target and the readback buffer for it. -/// -/// Spawns an `ImageCopier` entity the render graph will pick up via extract. -/// The returned `RenderTarget` should be attached to a `Camera2d`. -pub fn setup_offscreen_target( - commands: &mut Commands, - images: &mut Assets, - render_device: &RenderDevice, - width: u32, - height: u32, -) -> RenderTarget { - let size = Extent3d { - width, - height, - ..default() - }; - - // GPU-only texture the camera renders into. COPY_SRC lets us read it back. - let mut image = Image::new_target_texture(width, height, TextureFormat::Rgba8UnormSrgb, None); - image.texture_descriptor.usage |= TextureUsages::COPY_SRC; - let handle = images.add(image); - - commands.spawn(ImageCopier::new(handle.clone(), size, render_device)); - RenderTarget::Image(handle.into()) -} - -/// Strip the per-row padding from a mapped staging buffer to get tight -/// `width * height * 4` RGBA bytes. wgpu requires each row copied into a -/// buffer to be aligned to 256 bytes, so the buffer may be wider than the image. -pub fn unpad_rows(bytes: &[u8], width: u32, height: u32) -> Vec { - let row = width as usize * 4; - let padded = RenderDevice::align_copy_bytes_per_row(row); - if row == padded { - return bytes.to_vec(); - } - let mut out = Vec::with_capacity(row * height as usize); - for chunk in bytes.chunks_exact(padded).take(height as usize) { - out.extend_from_slice(&chunk[..row]); - } - out -} - -/// Marks a GPU image that should be copied out every frame. -#[derive(Component, Clone)] -struct ImageCopier { - buffer: Buffer, - src: Handle, - enabled: Arc, -} - -impl ImageCopier { - fn new(src: Handle, size: Extent3d, render_device: &RenderDevice) -> Self { - let padded_row = RenderDevice::align_copy_bytes_per_row(size.width as usize) * 4; - let buffer = render_device.create_buffer(&BufferDescriptor { - label: Some("phichain_readback_buffer"), - size: padded_row as u64 * size.height as u64, - usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - Self { - buffer, - src, - enabled: Arc::new(AtomicBool::new(true)), - } - } -} - -#[derive(Resource, Default, Deref)] -struct ExtractedCopiers(Vec); - -fn extract_copiers(mut commands: Commands, q: Extract>) { - commands.insert_resource(ExtractedCopiers(q.iter().cloned().collect())); -} - -#[derive(Debug, PartialEq, Eq, Clone, Hash, RenderLabel)] -struct CaptureLabel; - -struct CaptureNode; - -impl render_graph::Node for CaptureNode { - fn run( - &self, - _graph: &mut RenderGraphContext, - ctx: &mut RenderContext, - world: &World, - ) -> Result<(), NodeRunError> { - let Some(copiers) = world.get_resource::() else { - return Ok(()); - }; - let gpu_images = world.resource::>(); - let queue = world.resource::(); - - for copier in copiers.iter() { - if !copier.enabled.load(Ordering::Relaxed) { - continue; - } - let Some(src) = gpu_images.get(&copier.src) else { - continue; - }; - - let padded_row = - RenderDevice::align_copy_bytes_per_row(src.size.width as usize * 4) as u32; - - let mut encoder = ctx - .render_device() - .create_command_encoder(&CommandEncoderDescriptor::default()); - encoder.copy_texture_to_buffer( - src.texture.as_image_copy(), - TexelCopyBufferInfo { - buffer: &copier.buffer, - layout: TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(NonZero::new(padded_row).unwrap().into()), - rows_per_image: None, - }, - }, - src.size, - ); - queue.submit(std::iter::once(encoder.finish())); - } - - Ok(()) - } -} - -/// After the render graph runs, map the staging buffer and ship bytes back. -/// Blocks until the GPU finishes the copy (`poll` is mandatory on native). -fn send_frame( - copiers: Res, - render_device: Res, - sender: Res, -) { - for copier in copiers.iter() { - if !copier.enabled.load(Ordering::Relaxed) { - continue; - } - let slice = copier.buffer.slice(..); - - // map_async completes on the GPU's schedule; use a tiny channel to wait. - let (done_tx, done_rx) = crossbeam_channel::bounded(1); - slice.map_async(MapMode::Read, move |r| { - r.expect("buffer map failed"); - done_tx.send(()).unwrap(); - }); - render_device - .poll(PollType::wait_indefinitely()) - .expect("device poll"); - done_rx.recv().expect("map_async completion"); - - // Main world may have dropped its receiver on app exit — ignore errors. - let _ = sender.send(slice.get_mapped_range().to_vec()); - copier.buffer.unmap(); - } -} diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index 3399b1c2..c0331ea4 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -2,10 +2,14 @@ //! //! A child `ffmpeg` reads raw RGBA bytes from stdin at a fixed size/framerate //! and produces an h264 mp4. We drive `ChartTime` one video-frame at a time; -//! the game code renders a matching frame; we pipe the captured bytes to ffmpeg. +//! the game code renders a matching frame; Bevy's built-in `Readback` copies +//! that frame out of the GPU and fires `ReadbackComplete`, which we observe +//! here to pipe the bytes into ffmpeg. use bevy::app::AppExit; use bevy::prelude::*; +use bevy::render::gpu_readback::ReadbackComplete; +use bevy::render::renderer::RenderDevice; use phichain_game::ChartTime; use std::collections::VecDeque; use std::io::Write; @@ -13,21 +17,12 @@ use std::process::{Child, Command, Stdio}; use std::time::Instant; use crate::args::Args; -use crate::capture::{unpad_rows, FrameReceiver}; /// Frames rendered before we start writing, giving the GPU time to warm up /// (shader compilation, first-frame cache misses). The earliest frames are /// often transparent or blocky and would show up as garbage. const WARMUP_FRAMES: u32 = 40; -pub struct EncoderPlugin; - -impl Plugin for EncoderPlugin { - fn build(&self, app: &mut App) { - app.add_systems(PostUpdate, write_frame); - } -} - #[derive(Resource)] pub struct Encoder { ffmpeg: Child, @@ -48,8 +43,6 @@ pub struct Encoder { } impl Encoder { - /// Spawn ffmpeg and return the encoder state. `from`/`to` bound the chart - /// time window to render (seconds). pub fn spawn(args: &Args, from: f32, to: f32) -> Self { let (width, height, fps) = (args.video.width, args.video.height, args.video.fps); let total_frames = (fps as f32 * (to - from)) as u32; @@ -92,28 +85,23 @@ impl Encoder { } } -fn write_frame( +/// Observer fired by Bevy's `GpuReadbackPlugin` each time a frame has been +/// copied back from the GPU. +pub fn on_frame_ready( + event: On, mut enc: ResMut, mut chart_time: ResMut, - receiver: Res, mut exit: MessageWriter, ) { - // Advance the chart one video-frame at a time. chart_time.0 = enc.next_chart_time(); - // Render world may enqueue multiple frames per tick if the GPU is fast; - // only the most recent one matches current state. - let Some(bytes) = receiver.try_iter().last() else { - return; - }; - if enc.warmup_remaining > 0 { enc.warmup_remaining -= 1; return; } let (width, height) = (enc.width, enc.height); - let pixels = unpad_rows(&bytes, width, height); + let pixels = unpad_rows(&event.data, width, height); let stdin = enc .ffmpeg .stdin @@ -136,11 +124,25 @@ fn write_frame( } } +/// Strip the per-row padding wgpu adds when copying a texture into a buffer +/// (rows are aligned to 256 bytes). +fn unpad_rows(bytes: &[u8], width: u32, height: u32) -> Vec { + let row = width as usize * 4; + let padded = RenderDevice::align_copy_bytes_per_row(row); + if row == padded { + return bytes.to_vec(); + } + let mut out = Vec::with_capacity(row * height as usize); + for chunk in bytes.chunks_exact(padded).take(height as usize) { + out.extend_from_slice(&chunk[..row]); + } + out +} + fn log_progress(enc: &mut Encoder) { let elapsed = enc.start.elapsed().as_secs_f32(); let second = elapsed as u32; - // Sliding 1-second window of frame timestamps. enc.frame_times.push_back(elapsed); while enc.frame_times.front().is_some_and(|t| elapsed - *t > 1.0) { enc.frame_times.pop_front(); diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index a95bba83..6c41691c 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -1,29 +1,29 @@ //! Phichain offscreen video renderer. //! //! Pipeline: -//! 1. Run the game logic headlessly (no window) with `ChartTime` stepped -//! one video-frame at a time. +//! 1. Game logic runs headlessly with `ChartTime` stepped one video-frame +//! at a time. //! 2. A 2D camera renders each frame into an offscreen GPU texture. -//! 3. `CapturePlugin` copies that texture back to the CPU and pushes raw -//! RGBA bytes through a channel. -//! 4. `EncoderPlugin` feeds those bytes into an ffmpeg subprocess, which -//! encodes the mp4 on the side. +//! 3. Bevy's built-in `Readback` component copies that texture back to the +//! CPU each frame, firing `ReadbackComplete`. +//! 4. The observer in `encoder` feeds the bytes into an ffmpeg subprocess +//! which encodes the mp4 on the side. mod args; -mod capture; mod encoder; mod respack; mod utils; use crate::args::Args; -use crate::capture::{setup_offscreen_target, CapturePlugin}; -use crate::encoder::{Encoder, EncoderPlugin}; +use crate::encoder::{on_frame_ready, Encoder}; use crate::respack::RespackPlugin; use bevy::app::ScheduleRunnerPlugin; +use bevy::camera::RenderTarget; use bevy::core_pipeline::tonemapping::Tonemapping; use bevy::log::LogPlugin; use bevy::prelude::*; -use bevy::render::renderer::RenderDevice; +use bevy::render::gpu_readback::Readback; +use bevy::render::render_resource::{TextureFormat, TextureUsages}; use bevy::window::ExitCondition; use bevy::winit::WinitPlugin; use bevy_kira_audio::AudioPlugin; @@ -52,7 +52,10 @@ fn main() { ..default() }) .set(LogPlugin { - filter: "warn,phichain_renderer=info".to_string(), + // Silence the shutdown-time readback-channel warning; we + // intentionally exit with a few readbacks still in flight. + filter: "warn,phichain_renderer=info,bevy_render::gpu_readback=error" + .to_string(), level: bevy::log::Level::DEBUG, ..default() }) @@ -65,8 +68,6 @@ fn main() { .add_plugins(AssetsPlugin) .add_plugins(RespackPlugin) .add_plugins(GamePlugin) - .add_plugins(CapturePlugin) - .add_plugins(EncoderPlugin) .add_systems(Startup, setup) .run(); @@ -82,7 +83,6 @@ fn setup( mut viewport: ResMut, mut paused: ResMut, mut game_config: ResMut, - render_device: Res, args: Res, ) { let project = Project::open(args.path.clone().into()).expect("failed to open project"); @@ -94,23 +94,28 @@ fn setup( ) .expect("failed to read audio duration"); - let target = setup_offscreen_target( - &mut commands, - &mut images, - &render_device, + // Offscreen GPU texture the camera renders into; Readback copies it out each frame. + let mut target = Image::new_target_texture( args.video.width, args.video.height, + TextureFormat::Rgba8UnormSrgb, + None, ); + target.texture_descriptor.usage |= TextureUsages::COPY_SRC; + let target_handle = images.add(target); commands.spawn(( Camera2d, - target, - // The render target is already sRGB; writing values as-is matches the - // in-editor preview. + RenderTarget::Image(target_handle.clone().into()), + // The target is already sRGB; tonemapping would double-encode. Tonemapping::None, IsDefaultUiCamera, )); + commands + .spawn(Readback::texture(target_handle)) + .observe(on_frame_ready); + // Stand in for the main-window surrogate values the game code reads. viewport.0 = Rect::from_corners( Vec2::ZERO, From f2e24aade717d3843e2c41bf2b1de61aef9b0589 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 20 Apr 2026 14:44:26 +0900 Subject: [PATCH 03/27] removed default_nearest config --- phichain-renderer/src/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index 6c41691c..25e01491 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -45,7 +45,6 @@ fn main() { .insert_resource(args) .add_plugins( DefaultPlugins - .set(ImagePlugin::default_nearest()) .set(WindowPlugin { primary_window: None, exit_condition: ExitCondition::DontExit, From 864831da8f6923f9e967bed5e65de3b30a94acc5 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 20 Apr 2026 15:30:50 +0900 Subject: [PATCH 04/27] added video quality flags to phichain-renderer --- phichain-renderer/src/args.rs | 64 +++++++++++++++++++++++++-- phichain-renderer/src/encoder.rs | 75 +++++++++++++++++++++++++++++--- phichain-renderer/src/main.rs | 1 + 3 files changed, 129 insertions(+), 11 deletions(-) diff --git a/phichain-renderer/src/args.rs b/phichain-renderer/src/args.rs index fcd59b3d..3faa0234 100644 --- a/phichain-renderer/src/args.rs +++ b/phichain-renderer/src/args.rs @@ -1,5 +1,6 @@ use bevy::prelude::Resource; -use clap::Parser; +use bevy::render::view::Msaa; +use clap::{Parser, ValueEnum}; use phichain_game::GameConfig; use std::path::PathBuf; @@ -36,15 +37,70 @@ pub struct Args { #[command(next_help_heading = "Video Options")] pub struct VideoArgs { /// The width of the video - #[arg(long, default_value_t = 1920)] + #[arg(long, default_value_t = 1920, value_parser = clap::value_parser!(u32).range(1..=16384))] pub width: u32, /// The height of the video - #[arg(long, default_value_t = 1080)] + #[arg(long, default_value_t = 1080, value_parser = clap::value_parser!(u32).range(1..=16384))] pub height: u32, /// The fps of the video - #[arg(long, default_value_t = 60)] + #[arg(long, default_value_t = 60, value_parser = clap::value_parser!(u32).range(1..=240))] pub fps: u32, + + /// Multi-sample anti-aliasing level + #[arg(long, value_enum, default_value_t = MsaaLevel::Four)] + pub msaa: MsaaLevel, + + /// Use a platform-appropriate hardware video encoder (videotoolbox / nvenc / qsv). + /// Faster than software encoding but quality at a given CRF/bitrate is slightly lower + #[arg(long)] + pub hwaccel: bool, + + /// Video codec + #[arg(long, value_enum, default_value_t = Codec::H264)] + pub codec: Codec, + + /// Constant Rate Factor: 0 (lossless) to 51 (worst). 18 is "visually lossless". + /// For hardware encoders this is mapped to the encoder's native quality knob. + /// Mutually exclusive with --bitrate. + #[arg( + long, + default_value_t = 18, + value_parser = clap::value_parser!(u32).range(0..=51), + conflicts_with = "bitrate", + )] + pub crf: u32, + + /// Target bitrate (e.g. "8M", "6000k"). Mutually exclusive with --crf. + #[arg(long)] + pub bitrate: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum MsaaLevel { + Off, + #[value(name = "2")] + Two, + #[value(name = "4")] + Four, +} + +impl MsaaLevel { + pub fn into_msaa(self) -> Msaa { + match self { + MsaaLevel::Off => Msaa::Off, + MsaaLevel::Two => Msaa::Sample2, + MsaaLevel::Four => Msaa::Sample4, + } + } +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum Codec { + #[value(name = "h264")] + H264, + #[value(name = "h265")] + H265, } #[derive(Debug, Clone, Parser)] diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index c0331ea4..38639fe2 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -16,7 +16,7 @@ use std::io::Write; use std::process::{Child, Command, Stdio}; use std::time::Instant; -use crate::args::Args; +use crate::args::{Args, Codec}; /// Frames rendered before we start writing, giving the GPU time to warm up /// (shader compilation, first-frame cache misses). The earliest frames are @@ -47,15 +47,22 @@ impl Encoder { let (width, height, fps) = (args.video.width, args.video.height, args.video.fps); let total_frames = (fps as f32 * (to - from)) as u32; - let ffmpeg = Command::new("ffmpeg") - .args(["-y", "-f", "rawvideo", "-pix_fmt", "rgba"]) + let mut cmd = Command::new("ffmpeg"); + cmd.args(["-y", "-f", "rawvideo", "-pix_fmt", "rgba"]) .args(["-s", &format!("{width}x{height}")]) .args(["-framerate", &fps.to_string()]) - .args(["-an", "-i", "-"]) - .args(["-c:v", "libx264"]) - .arg(&args.output) + .args(["-an", "-i", "-"]); + + let encoder = pick_encoder(args.video.codec, args.video.hwaccel); + cmd.args(["-c:v", encoder]); + for arg in build_quality_args(args, encoder) { + cmd.arg(arg); + } + + cmd.arg(&args.output) .stdin(Stdio::piped()) - .stderr(Stdio::null()) + .stderr(Stdio::null()); + let ffmpeg = cmd .spawn() .expect("failed to spawn ffmpeg (is it on PATH?)"); @@ -124,6 +131,60 @@ pub fn on_frame_ready( } } +/// Pick the ffmpeg encoder name for a `(codec, hardware-accel)` combination. +/// Hardware encoder selection is best-effort per-platform. +fn pick_encoder(codec: Codec, hwaccel: bool) -> &'static str { + match (codec, hwaccel) { + (Codec::H264, false) => "libx264", + (Codec::H265, false) => "libx265", + (Codec::H264, true) => { + if cfg!(target_os = "macos") { + "h264_videotoolbox" + } else if cfg!(target_os = "windows") { + "h264_qsv" + } else { + "h264_nvenc" + } + } + (Codec::H265, true) => { + if cfg!(target_os = "macos") { + "hevc_videotoolbox" + } else if cfg!(target_os = "windows") { + "hevc_qsv" + } else { + "hevc_nvenc" + } + } + } +} + +/// Translate our `--bitrate` / `--crf` flags into ffmpeg args for the chosen encoder. +/// Each encoder family uses a different quality knob. +fn build_quality_args(args: &Args, encoder: &str) -> Vec { + let mut out = Vec::new(); + if let Some(rate) = &args.video.bitrate { + out.push("-b:v".into()); + out.push(rate.clone()); + } else { + let crf = args.video.crf; + let (flag, value) = if encoder.ends_with("videotoolbox") { + // videotoolbox uses -q:v 1..100 (higher = better). Approximate map. + let q = (100i32 - crf as i32 * 2).clamp(1, 100); + ("-q:v", q.to_string()) + } else if encoder.ends_with("nvenc") { + ("-cq", crf.to_string()) + } else if encoder.ends_with("qsv") { + ("-global_quality", crf.to_string()) + } else { + // libx264 / libx265 + ("-crf", crf.to_string()) + }; + out.push(flag.into()); + out.push(value); + } + out +} + /// Strip the per-row padding wgpu adds when copying a texture into a buffer /// (rows are aligned to 256 bytes). fn unpad_rows(bytes: &[u8], width: u32, height: u32) -> Vec { diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index 25e01491..7b1656b4 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -109,6 +109,7 @@ fn setup( // The target is already sRGB; tonemapping would double-encode. Tonemapping::None, IsDefaultUiCamera, + args.video.msaa.into_msaa(), )); commands From a8d0659555811749d1eb2a3a4ef850216096a251 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 20 Apr 2026 16:06:30 +0900 Subject: [PATCH 05/27] added audio rendering to phichain-renderer --- Cargo.lock | 3 + phichain-renderer/Cargo.toml | 3 + phichain-renderer/src/audio.rs | 213 +++++++++++++++++++++++++++++++ phichain-renderer/src/encoder.rs | 20 ++- phichain-renderer/src/main.rs | 8 +- 5 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 phichain-renderer/src/audio.rs diff --git a/Cargo.lock b/Cargo.lock index 75b79bc7..f3af913b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6128,9 +6128,12 @@ dependencies = [ "bevy", "bevy_kira_audio", "clap", + "hound", "phichain-assets", "phichain-chart", "phichain-game", + "serde_json", + "tempfile", ] [[package]] diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index c586bf6e..6a9577d1 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -16,3 +16,6 @@ bevy = { workspace = true } bevy_kira_audio = { workspace = true } clap = { version = "4.5.15", features = ["derive"] } anyhow = "1.0.86" +serde_json = "1.0.117" +hound = "3.5.1" +tempfile = "3.10" diff --git a/phichain-renderer/src/audio.rs b/phichain-renderer/src/audio.rs new file mode 100644 index 00000000..5a22aed2 --- /dev/null +++ b/phichain-renderer/src/audio.rs @@ -0,0 +1,213 @@ +//! Mix music + hit sounds into a temp WAV consumed by the encoder. + +use anyhow::{bail, Context, Result}; +use bevy::log::info; +use hound::{SampleFormat, WavSpec, WavWriter}; +use phichain_assets::{builtin_respack_dir, load_respack, LoadedAudio}; +use phichain_chart::bpm_list::BpmList; +use phichain_chart::migration::migrate; +use phichain_chart::note::NoteKind; +use phichain_chart::project::Project; +use phichain_chart::serialization::{PhichainChart, SerializedLine}; +use serde_json::Value; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Instant; +use tempfile::NamedTempFile; + +const SAMPLE_RATE: u32 = 48_000; +const CHANNELS: u16 = 2; + +/// Returned tempfile must outlive the ffmpeg process that reads it. +pub fn render_audio_track( + project: &Project, + respack: Option<&Path>, + from: f32, + to: f32, +) -> Result { + assert!(to > from, "--to must be greater than --from"); + + let started = Instant::now(); + + let chart = read_chart(project).context("read chart")?; + let offset_secs = chart.offset.0 / 1000.0; + let notes = collect_notes(&chart, from, to); + + let music_path = project + .path + .music_path() + .context("project is missing its music file")?; + let music_bytes = std::fs::read(&music_path) + .with_context(|| format!("read music file {}", music_path.display()))?; + let music = decode_pcm(&music_bytes).context("decode music")?; + let sfx = load_hit_sounds(respack).context("load hit sounds")?; + + let out_samples = + ((to - from) as f64 * SAMPLE_RATE as f64).round() as usize * CHANNELS as usize; + let mut buf = vec![0.0f32; out_samples]; + + overlay_music(&mut buf, &music, from + offset_secs); + accumulate(&mut buf, &sfx.tap, ¬es.taps, from); + accumulate(&mut buf, &sfx.drag, ¬es.drags, from); + accumulate(&mut buf, &sfx.flick, ¬es.flicks, from); + + let total_notes = notes.taps.len() + notes.drags.len() + notes.flicks.len(); + let temp = write_wav(&buf)?; + + info!( + "audio track ready: {} notes over {:.2}s mixed in {:.2}s", + total_notes, + to - from, + started.elapsed().as_secs_f32() + ); + + Ok(temp) +} + +fn read_chart(project: &Project) -> Result { + let file = File::open(project.path.chart_path())?; + let raw: Value = serde_json::from_reader(file)?; + let migrated = migrate(&raw)?; + Ok(serde_json::from_value(migrated)?) +} + +#[derive(Default)] +struct NoteTimes { + // Hold onsets share the tap sfx, same as phichain-editor. + taps: Vec, + drags: Vec, + flicks: Vec, +} + +fn collect_notes(chart: &PhichainChart, from: f32, to: f32) -> NoteTimes { + let mut out = NoteTimes::default(); + for line in &chart.lines { + walk_line(line, &chart.bpm_list, from, to, &mut out); + } + out +} + +fn walk_line(line: &SerializedLine, bpm: &BpmList, from: f32, to: f32, out: &mut NoteTimes) { + for note in &line.notes { + let t = bpm.time_at(note.beat); + if t < from || t >= to { + continue; + } + match note.kind { + NoteKind::Tap | NoteKind::Hold { .. } => out.taps.push(t), + NoteKind::Drag => out.drags.push(t), + NoteKind::Flick => out.flicks.push(t), + } + } + for child in &line.children { + walk_line(child, bpm, from, to, out); + } +} + +struct HitSounds { + tap: Vec, + drag: Vec, + flick: Vec, +} + +fn load_hit_sounds(respack: Option<&Path>) -> Result { + let pack = match respack { + Some(path) => { + load_respack(path).with_context(|| format!("load respack at {}", path.display()))? + } + None => load_respack(&builtin_respack_dir()).context("load built-in respack")?, + }; + let LoadedAudio { tap, drag, flick } = pack.audio; + Ok(HitSounds { + tap: decode_pcm(&tap)?, + drag: decode_pcm(&drag)?, + flick: decode_pcm(&flick)?, + }) +} + +fn decode_pcm(bytes: &[u8]) -> Result> { + let mut child = Command::new("ffmpeg") + .args(["-v", "error", "-i", "-"]) + .args(["-f", "f32le", "-ar", "48000", "-ac", "2", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("spawn ffmpeg")?; + + // Close stdin before waiting; otherwise ffmpeg blocks on EOF. + { + let mut stdin = child.stdin.take().expect("piped stdin"); + stdin.write_all(bytes)?; + } + + let output = child.wait_with_output()?; + if !output.status.success() { + bail!( + "ffmpeg failed decoding audio: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(pcm_bytes_to_f32(&output.stdout)) +} + +fn pcm_bytes_to_f32(raw: &[u8]) -> Vec { + raw.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +fn overlay_music(out: &mut [f32], music: &[f32], music_start_secs: f32) { + let offset_samples = + (music_start_secs * SAMPLE_RATE as f32).round() as isize * CHANNELS as isize; + let (src_start, dst_start) = if offset_samples >= 0 { + (offset_samples as usize, 0) + } else { + (0, (-offset_samples) as usize) + }; + if src_start >= music.len() || dst_start >= out.len() { + return; + } + let copy_len = (music.len() - src_start).min(out.len() - dst_start); + out[dst_start..dst_start + copy_len].copy_from_slice(&music[src_start..src_start + copy_len]); +} + +fn accumulate(out: &mut [f32], sfx: &[f32], times: &[f32], from: f32) { + let channels = CHANNELS as usize; + for &t in times { + let frame = ((t - from) * SAMPLE_RATE as f32).round() as isize; + if frame < 0 { + continue; + } + let start = frame as usize * channels; + if start >= out.len() { + continue; + } + let len = sfx.len().min(out.len() - start); + for (d, s) in out[start..start + len].iter_mut().zip(&sfx[..len]) { + *d += *s; + } + } +} + +fn write_wav(samples: &[f32]) -> Result { + let temp = tempfile::Builder::new() + .prefix("phichain_audio_") + .suffix(".wav") + .tempfile()?; + let file = temp.reopen()?; + let spec = WavSpec { + channels: CHANNELS, + sample_rate: SAMPLE_RATE, + bits_per_sample: 32, + sample_format: SampleFormat::Float, + }; + let mut writer = WavWriter::new(BufWriter::new(file), spec)?; + for &s in samples { + writer.write_sample(s)?; + } + writer.finalize()?; + Ok(temp) +} diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index 38639fe2..06391375 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -15,6 +15,7 @@ use std::collections::VecDeque; use std::io::Write; use std::process::{Child, Command, Stdio}; use std::time::Instant; +use tempfile::NamedTempFile; use crate::args::{Args, Codec}; @@ -40,18 +41,24 @@ pub struct Encoder { frame_times: VecDeque, last_log_second: u32, last_fps: usize, + + // Keep the WAV alive until ffmpeg exits. + _audio: NamedTempFile, } impl Encoder { - pub fn spawn(args: &Args, from: f32, to: f32) -> Self { + pub fn spawn(args: &Args, from: f32, to: f32, audio: NamedTempFile) -> Self { let (width, height, fps) = (args.video.width, args.video.height, args.video.fps); let total_frames = (fps as f32 * (to - from)) as u32; let mut cmd = Command::new("ffmpeg"); - cmd.args(["-y", "-f", "rawvideo", "-pix_fmt", "rgba"]) + cmd.args(["-y"]) + .args(["-f", "rawvideo", "-pix_fmt", "rgba"]) .args(["-s", &format!("{width}x{height}")]) .args(["-framerate", &fps.to_string()]) - .args(["-an", "-i", "-"]); + .args(["-i", "-"]) + .arg("-i") + .arg(audio.path()); let encoder = pick_encoder(args.video.codec, args.video.hwaccel); cmd.args(["-c:v", encoder]); @@ -59,6 +66,12 @@ impl Encoder { cmd.arg(arg); } + // alimiter catches additive overshoots from overlapping hit sounds. + cmd.args(["-c:a", "aac", "-b:a", "192k"]) + .args(["-af", "alimiter=limit=0.95:level=disabled"]) + .args(["-map", "0:v:0", "-map", "1:a:0"]) + .arg("-shortest"); + cmd.arg(&args.output) .stdin(Stdio::piped()) .stderr(Stdio::null()); @@ -80,6 +93,7 @@ impl Encoder { frame_times: VecDeque::new(), last_log_second: 0, last_fps: 0, + _audio: audio, } } diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index 7b1656b4..aaffe547 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -10,6 +10,7 @@ //! which encodes the mp4 on the side. mod args; +mod audio; mod encoder; mod respack; mod utils; @@ -129,7 +130,12 @@ fn setup( let from = args.from.unwrap_or(0.0); let to = args.to.unwrap_or(music_duration); - commands.insert_resource(Encoder::spawn(&args, from, to)); + + // Prepare audio before spawning the encoder. + // the encoder consumes the WAV as its second input, so it must exist on disk at spawn time. + let audio = audio::render_audio_track(&project, args.respack.as_deref(), from, to) + .expect("failed to render audio track"); + commands.insert_resource(Encoder::spawn(&args, from, to, audio)); phichain_game::loader::load_project(&project, &mut commands).expect("failed to load project"); } From 390a1b63b16771cc921b7801c908d297b8317518 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 20 Apr 2026 21:33:15 +0900 Subject: [PATCH 06/27] inlined single-use helpers in audio mixer --- phichain-renderer/src/audio.rs | 87 +++++++++++++--------------------- 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/phichain-renderer/src/audio.rs b/phichain-renderer/src/audio.rs index 5a22aed2..9bb71361 100644 --- a/phichain-renderer/src/audio.rs +++ b/phichain-renderer/src/audio.rs @@ -33,7 +33,8 @@ pub fn render_audio_track( let chart = read_chart(project).context("read chart")?; let offset_secs = chart.offset.0 / 1000.0; - let notes = collect_notes(&chart, from, to); + let mut notes = NoteTimes::default(); + collect_notes(&chart.lines, &chart.bpm_list, from, to, &mut notes); let music_path = project .path @@ -42,16 +43,27 @@ pub fn render_audio_track( let music_bytes = std::fs::read(&music_path) .with_context(|| format!("read music file {}", music_path.display()))?; let music = decode_pcm(&music_bytes).context("decode music")?; - let sfx = load_hit_sounds(respack).context("load hit sounds")?; + + let pack = match respack { + Some(path) => { + load_respack(path).with_context(|| format!("load respack at {}", path.display()))? + } + None => load_respack(&builtin_respack_dir()).context("load built-in respack")?, + }; + + let LoadedAudio { tap, drag, flick } = pack.audio; + let tap = decode_pcm(&tap).context("decode tap sfx")?; + let drag = decode_pcm(&drag).context("decode drag sfx")?; + let flick = decode_pcm(&flick).context("decode flick sfx")?; let out_samples = ((to - from) as f64 * SAMPLE_RATE as f64).round() as usize * CHANNELS as usize; let mut buf = vec![0.0f32; out_samples]; overlay_music(&mut buf, &music, from + offset_secs); - accumulate(&mut buf, &sfx.tap, ¬es.taps, from); - accumulate(&mut buf, &sfx.drag, ¬es.drags, from); - accumulate(&mut buf, &sfx.flick, ¬es.flicks, from); + accumulate(&mut buf, &tap, ¬es.taps, from); + accumulate(&mut buf, &drag, ¬es.drags, from); + accumulate(&mut buf, &flick, ¬es.flicks, from); let total_notes = notes.taps.len() + notes.drags.len() + notes.flicks.len(); let temp = write_wav(&buf)?; @@ -81,50 +93,21 @@ struct NoteTimes { flicks: Vec, } -fn collect_notes(chart: &PhichainChart, from: f32, to: f32) -> NoteTimes { - let mut out = NoteTimes::default(); - for line in &chart.lines { - walk_line(line, &chart.bpm_list, from, to, &mut out); - } - out -} - -fn walk_line(line: &SerializedLine, bpm: &BpmList, from: f32, to: f32, out: &mut NoteTimes) { - for note in &line.notes { - let t = bpm.time_at(note.beat); - if t < from || t >= to { - continue; - } - match note.kind { - NoteKind::Tap | NoteKind::Hold { .. } => out.taps.push(t), - NoteKind::Drag => out.drags.push(t), - NoteKind::Flick => out.flicks.push(t), +fn collect_notes(lines: &[SerializedLine], bpm: &BpmList, from: f32, to: f32, out: &mut NoteTimes) { + for line in lines { + for note in &line.notes { + let t = bpm.time_at(note.beat); + if t < from || t >= to { + continue; + } + match note.kind { + NoteKind::Tap | NoteKind::Hold { .. } => out.taps.push(t), + NoteKind::Drag => out.drags.push(t), + NoteKind::Flick => out.flicks.push(t), + } } + collect_notes(&line.children, bpm, from, to, out); } - for child in &line.children { - walk_line(child, bpm, from, to, out); - } -} - -struct HitSounds { - tap: Vec, - drag: Vec, - flick: Vec, -} - -fn load_hit_sounds(respack: Option<&Path>) -> Result { - let pack = match respack { - Some(path) => { - load_respack(path).with_context(|| format!("load respack at {}", path.display()))? - } - None => load_respack(&builtin_respack_dir()).context("load built-in respack")?, - }; - let LoadedAudio { tap, drag, flick } = pack.audio; - Ok(HitSounds { - tap: decode_pcm(&tap)?, - drag: decode_pcm(&drag)?, - flick: decode_pcm(&flick)?, - }) } fn decode_pcm(bytes: &[u8]) -> Result> { @@ -150,13 +133,11 @@ fn decode_pcm(bytes: &[u8]) -> Result> { String::from_utf8_lossy(&output.stderr).trim() ); } - Ok(pcm_bytes_to_f32(&output.stdout)) -} - -fn pcm_bytes_to_f32(raw: &[u8]) -> Vec { - raw.chunks_exact(4) + Ok(output + .stdout + .chunks_exact(4) .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) - .collect() + .collect()) } fn overlay_music(out: &mut [f32], music: &[f32], music_start_secs: f32) { From 3b118955dcddd35b51aa9f5af42828494cee09a8 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 20 Apr 2026 22:42:56 +0900 Subject: [PATCH 07/27] fixed pipe deadlock when decoding large audio files --- phichain-renderer/src/audio.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/phichain-renderer/src/audio.rs b/phichain-renderer/src/audio.rs index 9bb71361..30146f07 100644 --- a/phichain-renderer/src/audio.rs +++ b/phichain-renderer/src/audio.rs @@ -120,13 +120,16 @@ fn decode_pcm(bytes: &[u8]) -> Result> { .spawn() .context("spawn ffmpeg")?; - // Close stdin before waiting; otherwise ffmpeg blocks on EOF. - { - let mut stdin = child.stdin.take().expect("piped stdin"); - stdin.write_all(bytes)?; - } + // Feed stdin on a thread so ffmpeg can drain stdout in parallel. + // If we wrote stdin inline, a large input (a whole song) would fill the OS + // pipe buffers on both sides and deadlock. + let mut stdin = child.stdin.take().expect("piped stdin"); + let bytes = bytes.to_vec(); + let writer = std::thread::spawn(move || stdin.write_all(&bytes)); let output = child.wait_with_output()?; + writer.join().expect("stdin writer panicked")?; + if !output.status.success() { bail!( "ffmpeg failed decoding audio: {}", From 90cb3a15ed365fce0ca0e0c022c60ab747fab2c4 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 17:05:23 +0900 Subject: [PATCH 08/27] inserted AudioDuration in phichain-renderer so progress bar shows --- phichain-renderer/src/main.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index aaffe547..723b6265 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -31,6 +31,7 @@ use bevy_kira_audio::AudioPlugin; use clap::Parser; use phichain_assets::AssetsPlugin; use phichain_chart::project::Project; +use phichain_game::audio::AudioDuration; use phichain_game::{GameConfig, GamePlugin, GameSet, GameViewport, Paused}; use std::time::{Duration, Instant}; @@ -128,6 +129,10 @@ fn setup( .clone() .into_game_config(project.meta.name.clone(), project.meta.level.clone()); + // Game UI reads [`AudioDuration`] to render the progress bar; + // the renderer does not go through phichain_game::audio::load_audio, so insert it here manually. + commands.insert_resource(AudioDuration(Duration::from_secs_f32(music_duration))); + let from = args.from.unwrap_or(0.0); let to = args.to.unwrap_or(music_duration); From 983d9396e90a33c3e2a4642047f69164093419c4 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 17:29:42 +0900 Subject: [PATCH 09/27] replaced per-100-frame log lines with an indicatif progress bar --- Cargo.lock | 47 +++++++++++++++++++- phichain-renderer/Cargo.toml | 1 + phichain-renderer/src/encoder.rs | 73 +++++++++++++++----------------- 3 files changed, 80 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3af913b..11608a31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2424,7 +2424,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.1.11", ] [[package]] @@ -2465,6 +2465,19 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -3138,6 +3151,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -4349,6 +4368,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -5323,6 +5355,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "o2o" version = "0.5.4" @@ -6129,6 +6167,7 @@ dependencies = [ "bevy_kira_audio", "clap", "hound", + "indicatif", "phichain-assets", "phichain-chart", "phichain-game", @@ -8224,6 +8263,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index 6a9577d1..d3a42865 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -19,3 +19,4 @@ anyhow = "1.0.86" serde_json = "1.0.117" hound = "3.5.1" tempfile = "3.10" +indicatif = "0.17" diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index 06391375..70f5d602 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -10,8 +10,8 @@ use bevy::app::AppExit; use bevy::prelude::*; use bevy::render::gpu_readback::ReadbackComplete; use bevy::render::renderer::RenderDevice; +use indicatif::{ProgressBar, ProgressState, ProgressStyle}; use phichain_game::ChartTime; -use std::collections::VecDeque; use std::io::Write; use std::process::{Child, Command, Stdio}; use std::time::Instant; @@ -35,12 +35,9 @@ pub struct Encoder { warmup_remaining: u32, frames_written: u32, - total_frames: u32, start: Instant, - frame_times: VecDeque, - last_log_second: u32, - last_fps: usize, + progress: ProgressBar, // Keep the WAV alive until ffmpeg exits. _audio: NamedTempFile, @@ -79,6 +76,8 @@ impl Encoder { .spawn() .expect("failed to spawn ffmpeg (is it on PATH?)"); + let progress = build_progress_bar(total_frames as u64, fps); + Self { ffmpeg, width, @@ -88,11 +87,8 @@ impl Encoder { to, warmup_remaining: WARMUP_FRAMES, frames_written: 0, - total_frames, start: Instant::now(), - frame_times: VecDeque::new(), - last_log_second: 0, - last_fps: 0, + progress, _audio: audio, } } @@ -133,9 +129,17 @@ pub fn on_frame_ready( .expect("failed to write frame to ffmpeg"); enc.frames_written += 1; - log_progress(&mut enc); + enc.progress.set_position(enc.frames_written as u64); if enc.done() { + enc.progress.finish_and_clear(); + let elapsed = enc.start.elapsed().as_secs_f32(); + let avg_fps = enc.frames_written as f32 / elapsed; + let realtime = avg_fps / enc.fps as f32; + info!( + "encoded {} frames in {:.2}s (avg {:.0} fps, {:.2}x realtime)", + enc.frames_written, elapsed, avg_fps, realtime, + ); // Closing stdin signals EOF; ffmpeg finalizes the file on its own. drop(enc.ffmpeg.stdin.take()); enc.ffmpeg @@ -214,34 +218,23 @@ fn unpad_rows(bytes: &[u8], width: u32, height: u32) -> Vec { out } -fn log_progress(enc: &mut Encoder) { - let elapsed = enc.start.elapsed().as_secs_f32(); - let second = elapsed as u32; - - enc.frame_times.push_back(elapsed); - while enc.frame_times.front().is_some_and(|t| elapsed - *t > 1.0) { - enc.frame_times.pop_front(); - } - if enc.last_log_second != second { - enc.last_fps = enc.frame_times.len(); - enc.last_log_second = second; - } - - if !enc.frames_written.is_multiple_of(100) { - return; - } - let eta = if enc.last_fps == 0 { - f32::INFINITY - } else { - enc.total_frames.saturating_sub(enc.frames_written) as f32 / enc.last_fps as f32 - }; - info!( - "{} / {} ({:.1}%), {} fps ({:.2}x), eta {:.1}s", - enc.frames_written, - enc.total_frames, - enc.frames_written as f32 / enc.total_frames.max(1) as f32 * 100.0, - enc.last_fps, - enc.last_fps as f32 / enc.fps as f32, - eta, - ); +/// Build the in-place progress bar shown during encoding. +/// The `fps` parameter is the target output framerate, used to compute the realtime multiplier. +fn build_progress_bar(total_frames: u64, fps: u32) -> ProgressBar { + let target_fps = fps as f64; + let template = "[{elapsed_precise}] [{bar:40.cyan/blue}] \ + {pos}/{len} ({percent:>3}%) {fps} ({rt}) eta {eta:>4}"; + let style = ProgressStyle::with_template(template) + .expect("progress bar template is valid") + .progress_chars("=> ") + .with_key("fps", |s: &ProgressState, w: &mut dyn std::fmt::Write| { + let _ = write!(w, "{:>3.0} fps", s.per_sec()); + }) + .with_key( + "rt", + move |s: &ProgressState, w: &mut dyn std::fmt::Write| { + let _ = write!(w, "{:.2}x", s.per_sec() / target_fps); + }, + ); + ProgressBar::new(total_frames).with_style(style) } From 33917894cb3a3972b5590d63fcd96b793f09c1e9 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 18:21:04 +0900 Subject: [PATCH 10/27] setup i18n --- Cargo.lock | 2 ++ phichain-renderer/Cargo.toml | 2 ++ phichain-renderer/src/i18n.rs | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 phichain-renderer/src/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index 11608a31..9b1f5d2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6171,7 +6171,9 @@ dependencies = [ "phichain-assets", "phichain-chart", "phichain-game", + "rust-i18n", "serde_json", + "sys-locale", "tempfile", ] diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index d3a42865..3d25dfee 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -20,3 +20,5 @@ serde_json = "1.0.117" hound = "3.5.1" tempfile = "3.10" indicatif = "0.17" +rust-i18n = "=3.0.1" +sys-locale = "0.3.2" diff --git a/phichain-renderer/src/i18n.rs b/phichain-renderer/src/i18n.rs new file mode 100644 index 00000000..f3e0866c --- /dev/null +++ b/phichain-renderer/src/i18n.rs @@ -0,0 +1,39 @@ +use rust_i18n::t; +use std::borrow::Cow; + +/// Normalize locale from system to rust-i18n format +fn normalize_locale(locale: &str) -> String { + // Remove encoding suffix and replace underscore + let base = locale.split('.').next().unwrap_or(locale).replace('_', "-"); + + // Map to available translation files + match base.as_str() { + "C" | "POSIX" => "en-US".to_string(), + // macOS verbose formats + "zh-Hans-CN" | "zh-Hans" | "zh-Hans-SG" => "zh-CN".to_string(), + // TODO: map these to zh-TW once a Traditional Chinese is supported. + "zh-TW" | "zh-HK" | "zh-MO" | "zh-Hant-CN" | "zh-Hant-TW" | "zh-Hant" | "zh-Hant-HK" + | "zh-Hant-MO" => "zh-CN".to_string(), + // Japanese (already matches filename ja-JP.yml) + // already normalized + _ => base, + } +} + +/// Get system locale with fallback +pub fn locale() -> String { + std::env::var("PHICHAIN_LANG") + .ok() + .map(|loc| normalize_locale(&loc)) + .or(sys_locale::get_locale().map(|loc| normalize_locale(&loc))) + .unwrap_or_else(|| "en-US".to_string()) +} + +// Leaks owned translations to produce `&'static str`. +// Acceptable here because the renderer is a short-lived CLI process. +pub fn i18n_str(key: &'static str) -> &'static str { + match t!(key) { + Cow::Borrowed(s) => s, + Cow::Owned(s) => Box::leak(s.into_boxed_str()), + } +} From dbc94d6dea2ff6ec9f5989ae6d2139a43211fb88 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 18:26:35 +0900 Subject: [PATCH 11/27] added i18n support for renderer --- phichain-renderer/locales/en-US.yml | 39 ++++++++++++++ phichain-renderer/locales/ja-JP.yml | 39 ++++++++++++++ phichain-renderer/locales/zh-CN.yml | 39 ++++++++++++++ phichain-renderer/src/args.rs | 82 ++++++++++++++--------------- phichain-renderer/src/audio.rs | 12 +++-- phichain-renderer/src/encoder.rs | 11 +++- phichain-renderer/src/main.rs | 13 ++++- phichain-renderer/src/respack.rs | 24 +++++++-- 8 files changed, 205 insertions(+), 54 deletions(-) create mode 100644 phichain-renderer/locales/en-US.yml create mode 100644 phichain-renderer/locales/ja-JP.yml create mode 100644 phichain-renderer/locales/zh-CN.yml diff --git a/phichain-renderer/locales/en-US.yml b/phichain-renderer/locales/en-US.yml new file mode 100644 index 00000000..606001da --- /dev/null +++ b/phichain-renderer/locales/en-US.yml @@ -0,0 +1,39 @@ +cli: + about: Renders Phichain projects to videos + + args: + path: The path to the Phichain project + output: The path of the output video + from: The start time of the chart to render in seconds. 0.0 if not given + to: The end time of the chart to render in seconds. The duration of the music if not given + respack: Path to a custom resource pack (directory or .zip). The built-in pack is used if not given + + video: + heading: Video Options + width: The width of the video + height: The height of the video + fps: The fps of the video + msaa: Multi-sample anti-aliasing level + hwaccel: "Use a platform-appropriate hardware video encoder (videotoolbox / nvenc / qsv). Faster than software encoding but quality at a given CRF/bitrate is slightly lower" + codec: Video codec + crf: "Constant Rate Factor: 0 (lossless) to 51 (worst). 18 is \"visually lossless\". For hardware encoders this is mapped to the encoder's native quality knob. Mutually exclusive with --bitrate." + bitrate: "Target bitrate (e.g. \"8M\", \"6000k\"). Mutually exclusive with --crf." + + game: + heading: Game Options + note_scale: The scale factor for notes + fc_ap_indicator: Enable the FC/AP indicator. Since phichain-renderer always use autoplay, enabling this will result in a constant yellow line + no_multi_highlight: Disable multi highlight for notes + hide_hit_effect: Hide hit effects + name: Overwrite the name of the chart + level: Overwrite the level of the chart + + status: + loaded_respack: "loaded custom respack: %{path}" + audio_ready: "audio track ready: %{notes} notes over %{duration}s mixed in %{elapsed}s" + encoded: "encoded %{frames} frames in %{elapsed}s (avg %{fps} fps, %{realtime}x realtime)" + completed: "render completed in %{elapsed}s" + + error: + load_respack_failed: "failed to load respack %{path}: %{error}" + apply_respack_failed: "failed to apply respack %{path}: %{error}" diff --git a/phichain-renderer/locales/ja-JP.yml b/phichain-renderer/locales/ja-JP.yml new file mode 100644 index 00000000..793a0572 --- /dev/null +++ b/phichain-renderer/locales/ja-JP.yml @@ -0,0 +1,39 @@ +cli: + about: Phichain プロジェクトをビデオにレンダリングします + + args: + path: Phichain プロジェクトのパス + output: 出力ビデオのパス + from: レンダリング開始時間 (秒)。指定しない場合は 0.0 から + to: レンダリング終了時間 (秒)。指定しない場合は音声の長さを使用 + respack: カスタムリソースパックのパス (ディレクトリまたは .zip)。指定しない場合は内蔵パックを使用 + + video: + heading: 'ビデオオプション' + width: ビデオの幅 + height: ビデオの高さ + fps: ビデオのフレームレート + msaa: マルチサンプルアンチエイリアスのレベル + hwaccel: プラットフォームに応じたハードウェアビデオエンコーダー (videotoolbox / nvenc / qsv) を使用します。ソフトウェアエンコードより高速ですが、同じ CRF/ビットレートでは品質がやや低くなります + codec: ビデオコーデック + crf: '固定品質係数:0 (ロスレス) から 51 (最低)、18 は視覚的にロスレス。ハードウェアエンコーダーの場合は対応するネイティブ品質パラメータにマッピングされます。--bitrate と排他' + bitrate: 'ターゲットビットレート (例: "8M"、"6000k")。--crf と排他' + + game: + heading: 'ゲームオプション' + note_scale: ノートのスケール係数 + fc_ap_indicator: FC/AP インジケータを有効にします。phichain-renderer は常にオートプレイを使用するため、有効にすると判定ラインが常に黄色になります + no_multi_highlight: ノートのマルチハイライトを無効にする + hide_hit_effect: 打撃エフェクトを非表示にする + name: 譜面名を上書き + level: 譜面難易度を上書き + + status: + loaded_respack: 'カスタムリソースパックを読み込みました: %{path}' + audio_ready: '音声トラック準備完了: %{notes} ノート、長さ %{duration}s、ミキシング時間 %{elapsed}s' + encoded: '%{frames} フレームをエンコード、所要時間 %{elapsed}s (平均 %{fps} fps、%{realtime}x リアルタイム)' + completed: 'レンダリング完了、所要時間 %{elapsed}s' + + error: + load_respack_failed: 'リソースパックの読み込みに失敗 %{path}: %{error}' + apply_respack_failed: 'リソースパックの適用に失敗 %{path}: %{error}' diff --git a/phichain-renderer/locales/zh-CN.yml b/phichain-renderer/locales/zh-CN.yml new file mode 100644 index 00000000..11e058fa --- /dev/null +++ b/phichain-renderer/locales/zh-CN.yml @@ -0,0 +1,39 @@ +cli: + about: 将 Phichain 项目渲染为视频 + + args: + path: Phichain 项目的路径 + output: 输出视频的路径 + from: 渲染起始时间 (秒)。不指定时从 0.0 开始 + to: 渲染结束时间 (秒)。不指定时使用音乐时长 + respack: 自定义资源包路径 (目录或 .zip)。不指定时使用内置资源包 + + video: + heading: '视频选项' + width: 视频宽度 + height: 视频高度 + fps: 视频帧率 + msaa: 多重采样抗锯齿级别 + hwaccel: 使用平台对应的硬件视频编码器 (videotoolbox / nvenc / qsv)。比软件编码快,但在同等 CRF/码率下质量略低 + codec: 视频编码器 + crf: '固定质量因子:0 (无损) 到 51 (最差),18 为视觉无损。对于硬件编码器,会映射到对应的原生质量参数。与 --bitrate 互斥' + bitrate: '目标码率 (例如 "8M"、"6000k")。与 --crf 互斥' + + game: + heading: '游戏选项' + note_scale: 音符的缩放系数 + fc_ap_indicator: 启用 FC/AP 指示器。由于 phichain-renderer 始终使用自动演奏,启用后判定线会一直保持黄色 + no_multi_highlight: 禁用音符的多重高亮 + hide_hit_effect: 隐藏打击特效 + name: 覆盖谱面名称 + level: 覆盖谱面难度 + + status: + loaded_respack: '已加载自定义资源包: %{path}' + audio_ready: '音轨已就绪: %{notes} 个音符,总长 %{duration}s,混音耗时 %{elapsed}s' + encoded: '已编码 %{frames} 帧,耗时 %{elapsed}s (平均 %{fps} fps,%{realtime}x 实时速度)' + completed: '渲染完成,耗时 %{elapsed}s' + + error: + load_respack_failed: '加载资源包失败 %{path}: %{error}' + apply_respack_failed: '应用资源包失败 %{path}: %{error}' diff --git a/phichain-renderer/src/args.rs b/phichain-renderer/src/args.rs index 3faa0234..3f8db6d8 100644 --- a/phichain-renderer/src/args.rs +++ b/phichain-renderer/src/args.rs @@ -1,78 +1,81 @@ +use crate::i18n::i18n_str; use bevy::prelude::Resource; use bevy::render::view::Msaa; use clap::{Parser, ValueEnum}; use phichain_game::GameConfig; +use rust_i18n::t; use std::path::PathBuf; -/// Render Phigros charts into videos #[derive(Debug, Clone, Parser, Resource)] +#[command(about = i18n_str("cli.about"))] pub struct Args { - // ------ Video Config ------ - /// The path to the Phichain project + #[arg(help = t!("cli.args.path").to_string())] pub path: String, - /// The path of the output video - #[arg(short, long, default_value = "output.mp4")] + #[arg(short, long, default_value = "output.mp4", help = t!("cli.args.output").to_string())] pub output: String, - /// The start time of the chart to render in seconds. 0.0 if not given - #[arg(long)] + #[arg(long, help = t!("cli.args.from").to_string())] pub from: Option, - /// The end time of the chart to render in seconds. the duration of the music if not given - #[arg(long)] + #[arg(long, help = t!("cli.args.to").to_string())] pub to: Option, - /// Path to a custom resource pack (directory or .zip). The built-in pack is used if not given - #[arg(long)] + #[arg(long, help = t!("cli.args.respack").to_string())] pub respack: Option, #[command(flatten)] + #[command(next_help_heading = i18n_str("cli.video.heading"))] pub video: VideoArgs, #[command(flatten)] + #[command(next_help_heading = i18n_str("cli.game.heading"))] pub game: GameArgs, } #[derive(Debug, Clone, Parser)] -#[command(next_help_heading = "Video Options")] pub struct VideoArgs { - /// The width of the video - #[arg(long, default_value_t = 1920, value_parser = clap::value_parser!(u32).range(1..=16384))] + #[arg( + long, + default_value_t = 1920, + value_parser = clap::value_parser!(u32).range(1..=16384), + help = t!("cli.video.width").to_string(), + )] pub width: u32, - /// The height of the video - #[arg(long, default_value_t = 1080, value_parser = clap::value_parser!(u32).range(1..=16384))] + #[arg( + long, + default_value_t = 1080, + value_parser = clap::value_parser!(u32).range(1..=16384), + help = t!("cli.video.height").to_string(), + )] pub height: u32, - /// The fps of the video - #[arg(long, default_value_t = 60, value_parser = clap::value_parser!(u32).range(1..=240))] + #[arg( + long, + default_value_t = 60, + value_parser = clap::value_parser!(u32).range(1..=240), + help = t!("cli.video.fps").to_string(), + )] pub fps: u32, - /// Multi-sample anti-aliasing level - #[arg(long, value_enum, default_value_t = MsaaLevel::Four)] + #[arg(long, value_enum, default_value_t = MsaaLevel::Four, help = t!("cli.video.msaa").to_string())] pub msaa: MsaaLevel, - /// Use a platform-appropriate hardware video encoder (videotoolbox / nvenc / qsv). - /// Faster than software encoding but quality at a given CRF/bitrate is slightly lower - #[arg(long)] + #[arg(long, help = t!("cli.video.hwaccel").to_string())] pub hwaccel: bool, - /// Video codec - #[arg(long, value_enum, default_value_t = Codec::H264)] + #[arg(long, value_enum, default_value_t = Codec::H264, help = t!("cli.video.codec").to_string())] pub codec: Codec, - /// Constant Rate Factor: 0 (lossless) to 51 (worst). 18 is "visually lossless". - /// For hardware encoders this is mapped to the encoder's native quality knob. - /// Mutually exclusive with --bitrate. #[arg( long, default_value_t = 18, value_parser = clap::value_parser!(u32).range(0..=51), conflicts_with = "bitrate", + help = t!("cli.video.crf").to_string(), )] pub crf: u32, - /// Target bitrate (e.g. "8M", "6000k"). Mutually exclusive with --crf. - #[arg(long)] + #[arg(long, help = t!("cli.video.bitrate").to_string())] pub bitrate: Option, } @@ -104,25 +107,18 @@ pub enum Codec { } #[derive(Debug, Clone, Parser)] -#[command(next_help_heading = "Game Options")] pub struct GameArgs { - /// The scale factor for notes - #[arg(long, default_value_t = 1.0)] + #[arg(long, default_value_t = 1.0, help = t!("cli.game.note_scale").to_string())] pub note_scale: f32, - /// Enable the FC/AP indicator. Since phichain-renderer always use autoplay, enabling this will result in a constant yellow line - #[arg(long)] + #[arg(long, help = t!("cli.game.fc_ap_indicator").to_string())] pub fc_ap_indicator: bool, - /// Disable multi highlight for notes - #[arg(long)] + #[arg(long, help = t!("cli.game.no_multi_highlight").to_string())] pub no_multi_highlight: bool, - /// Hide hit effects - #[arg(long)] + #[arg(long, help = t!("cli.game.hide_hit_effect").to_string())] pub hide_hit_effect: bool, - /// Overwrite the name of the chart - #[arg(long)] + #[arg(long, help = t!("cli.game.name").to_string())] pub name: Option, - /// Overwrite the level of the chart - #[arg(long)] + #[arg(long, help = t!("cli.game.level").to_string())] pub level: Option, } diff --git a/phichain-renderer/src/audio.rs b/phichain-renderer/src/audio.rs index 30146f07..db651362 100644 --- a/phichain-renderer/src/audio.rs +++ b/phichain-renderer/src/audio.rs @@ -9,6 +9,7 @@ use phichain_chart::migration::migrate; use phichain_chart::note::NoteKind; use phichain_chart::project::Project; use phichain_chart::serialization::{PhichainChart, SerializedLine}; +use rust_i18n::t; use serde_json::Value; use std::fs::File; use std::io::{BufWriter, Write}; @@ -69,10 +70,13 @@ pub fn render_audio_track( let temp = write_wav(&buf)?; info!( - "audio track ready: {} notes over {:.2}s mixed in {:.2}s", - total_notes, - to - from, - started.elapsed().as_secs_f32() + "{}", + t!( + "cli.status.audio_ready", + notes = total_notes, + duration = format!("{:.2}", to - from), + elapsed = format!("{:.2}", started.elapsed().as_secs_f32()) + ) ); Ok(temp) diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index 70f5d602..7b4f09fc 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -12,6 +12,7 @@ use bevy::render::gpu_readback::ReadbackComplete; use bevy::render::renderer::RenderDevice; use indicatif::{ProgressBar, ProgressState, ProgressStyle}; use phichain_game::ChartTime; +use rust_i18n::t; use std::io::Write; use std::process::{Child, Command, Stdio}; use std::time::Instant; @@ -137,8 +138,14 @@ pub fn on_frame_ready( let avg_fps = enc.frames_written as f32 / elapsed; let realtime = avg_fps / enc.fps as f32; info!( - "encoded {} frames in {:.2}s (avg {:.0} fps, {:.2}x realtime)", - enc.frames_written, elapsed, avg_fps, realtime, + "{}", + t!( + "cli.status.encoded", + frames = enc.frames_written, + elapsed = format!("{elapsed:.2}"), + fps = format!("{avg_fps:.0}"), + realtime = format!("{realtime:.2}") + ) ); // Closing stdin signals EOF; ffmpeg finalizes the file on its own. drop(enc.ffmpeg.stdin.take()); diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index 723b6265..4fa73e16 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -12,11 +12,13 @@ mod args; mod audio; mod encoder; +mod i18n; mod respack; mod utils; use crate::args::Args; use crate::encoder::{on_frame_ready, Encoder}; +use crate::i18n::locale; use crate::respack::RespackPlugin; use bevy::app::ScheduleRunnerPlugin; use bevy::camera::RenderTarget; @@ -33,10 +35,14 @@ use phichain_assets::AssetsPlugin; use phichain_chart::project::Project; use phichain_game::audio::AudioDuration; use phichain_game::{GameConfig, GamePlugin, GameSet, GameViewport, Paused}; +use rust_i18n::t; use std::time::{Duration, Instant}; +rust_i18n::i18n!("locales", fallback = "en-US"); + fn main() { phichain_assets::setup_assets(); + rust_i18n::set_locale(&locale()); let args = Args::parse(); let started = Instant::now(); @@ -73,8 +79,11 @@ fn main() { .run(); info!( - "render completed in {:.2}s", - started.elapsed().as_secs_f64() + "{}", + t!( + "cli.status.completed", + elapsed = format!("{:.2}", started.elapsed().as_secs_f64()) + ) ); } diff --git a/phichain-renderer/src/respack.rs b/phichain-renderer/src/respack.rs index 4c56a3c3..8d0c62b0 100644 --- a/phichain-renderer/src/respack.rs +++ b/phichain-renderer/src/respack.rs @@ -2,6 +2,7 @@ use bevy::prelude::*; use phichain_assets::{apply_respack, load_respack}; +use rust_i18n::t; use crate::args::Args; @@ -13,13 +14,30 @@ impl Plugin for RespackPlugin { return; }; let pack = load_respack(&path).unwrap_or_else(|err| { - eprintln!("error: failed to load respack {}: {err:#}", path.display()); + eprintln!( + "error: {}", + t!( + "cli.error.load_respack_failed", + path = path.display(), + error = format!("{err:#}") + ) + ); std::process::exit(1); }); if let Err(err) = apply_respack(pack, app.world_mut()) { - eprintln!("error: failed to apply respack {}: {err:#}", path.display()); + eprintln!( + "error: {}", + t!( + "cli.error.apply_respack_failed", + path = path.display(), + error = format!("{err:#}") + ) + ); std::process::exit(1); } - info!("loaded custom respack: {}", path.display()); + info!( + "{}", + t!("cli.status.loaded_respack", path = path.display()) + ); } } From 99d975c51c95893d9082fa3be42cd84201524329 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 21:08:59 +0900 Subject: [PATCH 12/27] added phichain-i18n for cross crate i18n support --- Cargo.toml | 1 + phichain-i18n/Cargo.toml | 8 +++ phichain-i18n/src/lib.rs | 110 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 phichain-i18n/Cargo.toml create mode 100644 phichain-i18n/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 33a675df..57d3f85f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "phichain-editor", "phichain-format", "phichain-game", + "phichain-i18n", "phichain-renderer", "phichain-telemetry", ] diff --git a/phichain-i18n/Cargo.toml b/phichain-i18n/Cargo.toml new file mode 100644 index 00000000..ef52b036 --- /dev/null +++ b/phichain-i18n/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "phichain-i18n" +version = "1.0.0-beta.6" +edition = "2021" + +[dependencies] +rust-i18n = "=3.0.1" +sys-locale = "0.3.2" diff --git a/phichain-i18n/src/lib.rs b/phichain-i18n/src/lib.rs new file mode 100644 index 00000000..8cd93c64 --- /dev/null +++ b/phichain-i18n/src/lib.rs @@ -0,0 +1,110 @@ +//! CLI i18n helpers shared by non-GUI phichain binaries. +//! +//! Supported locales (BCP 47): `en-US` (fallback), `zh-CN`, `ja-JP`. +//! +//! Each binary ships `locales/*.yml` and calls `rust_i18n::i18n!("locales", fallback = "en-US")` at its crate root. + +/// Map a raw locale string into one of the supported keys (`en-US`, `zh-CN`, `ja-JP`), or pass through unchanged. +/// +/// Accepts POSIX (`zh_CN.UTF-8`), macOS script-tagged BCP 47 (`zh-Hans-CN`), and bare language subtags (`ja`, `zh`). +pub fn normalize_locale(locale: &str) -> String { + let base = locale.split('.').next().unwrap_or(locale).replace('_', "-"); + + match base.as_str() { + "C" | "POSIX" => "en-US".to_string(), + "zh-Hans-CN" | "zh-Hans" | "zh-Hans-SG" => "zh-CN".to_string(), + // TODO: map to zh-TW once a Traditional Chinese translation exists. + "zh" | "zh-TW" | "zh-HK" | "zh-MO" | "zh-Hant-CN" | "zh-Hant-TW" | "zh-Hant" + | "zh-Hant-HK" | "zh-Hant-MO" => "zh-CN".to_string(), + "ja" => "ja-JP".to_string(), + _ => base, + } +} + +/// Resolve the active locale: `PHICHAIN_LANG` > system locale > en-US fallback. +pub fn locale() -> String { + std::env::var("PHICHAIN_LANG") + .ok() + .map(|loc| normalize_locale(&loc)) + .or(sys_locale::get_locale().map(|loc| normalize_locale(&loc))) + .unwrap_or_else(|| "en-US".to_string()) +} + +/// Resolve a translation key to `&'static str` by leaking, for clap attributes that require it. +/// +/// A macro, not a function, because `t!` must expand in the calling binary where `i18n!()` registered the translations. +#[macro_export] +macro_rules! i18n_str { + ($key:expr) => { + match ::rust_i18n::t!($key) { + ::std::borrow::Cow::Borrowed(s) => s, + ::std::borrow::Cow::Owned(s) => ::std::boxed::Box::leak(s.into_boxed_str()), + } + }; +} + +#[cfg(test)] +mod tests { + use super::normalize_locale; + + #[test] + fn strips_posix_encoding_suffix_and_rewrites_separator() { + assert_eq!(normalize_locale("zh_CN.UTF-8"), "zh-CN"); + assert_eq!(normalize_locale("en_US.UTF-8"), "en-US"); + assert_eq!(normalize_locale("ja_JP.UTF-8"), "ja-JP"); + } + + #[test] + fn rewrites_posix_separator_without_encoding() { + assert_eq!(normalize_locale("zh_CN"), "zh-CN"); + assert_eq!(normalize_locale("en_US"), "en-US"); + assert_eq!(normalize_locale("ja_JP"), "ja-JP"); + } + + #[test] + fn c_and_posix_map_to_english() { + assert_eq!(normalize_locale("C"), "en-US"); + assert_eq!(normalize_locale("POSIX"), "en-US"); + } + + #[test] + fn macos_simplified_script_tags_collapse_to_zh_cn() { + assert_eq!(normalize_locale("zh-Hans"), "zh-CN"); + assert_eq!(normalize_locale("zh-Hans-CN"), "zh-CN"); + assert_eq!(normalize_locale("zh-Hans-SG"), "zh-CN"); + } + + #[test] + fn traditional_chinese_variants_fall_back_to_zh_cn_for_now() { + // TODO: update this once zh-TW is added. + assert_eq!(normalize_locale("zh-TW"), "zh-CN"); + assert_eq!(normalize_locale("zh-HK"), "zh-CN"); + assert_eq!(normalize_locale("zh-MO"), "zh-CN"); + assert_eq!(normalize_locale("zh-Hant"), "zh-CN"); + assert_eq!(normalize_locale("zh-Hant-TW"), "zh-CN"); + assert_eq!(normalize_locale("zh-Hant-HK"), "zh-CN"); + } + + #[test] + fn bare_japanese_gets_regional_default() { + assert_eq!(normalize_locale("ja"), "ja-JP"); + } + + #[test] + fn supported_bcp47_tags_pass_through_unchanged() { + assert_eq!(normalize_locale("en-US"), "en-US"); + assert_eq!(normalize_locale("zh-CN"), "zh-CN"); + assert_eq!(normalize_locale("ja-JP"), "ja-JP"); + } + + #[test] + fn unsupported_locales_pass_through_so_they_fall_back_at_lookup() { + assert_eq!(normalize_locale("fr-FR"), "fr-FR"); + assert_eq!(normalize_locale("de"), "de"); + } + + #[test] + fn bare_chinese_gets_simplified_default() { + assert_eq!(normalize_locale("zh"), "zh-CN"); + } +} From a5fc9b2cb39587d0f47b8554535c41228c8e564e Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 21:09:36 +0900 Subject: [PATCH 13/27] converter and renderer use phichain-i18n --- Cargo.lock | 12 +++++++++-- phichain-converter/Cargo.toml | 2 +- phichain-converter/src/i18n.rs | 39 ---------------------------------- phichain-converter/src/main.rs | 15 ++++++------- phichain-renderer/Cargo.toml | 2 +- phichain-renderer/src/args.rs | 8 +++---- phichain-renderer/src/i18n.rs | 39 ---------------------------------- phichain-renderer/src/main.rs | 3 +-- 8 files changed, 24 insertions(+), 96 deletions(-) delete mode 100644 phichain-converter/src/i18n.rs delete mode 100644 phichain-renderer/src/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index 9b1f5d2e..e57ef6af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6113,12 +6113,12 @@ dependencies = [ "owo-colors", "phichain-chart", "phichain-format", + "phichain-i18n", "phichain-telemetry", "rust-i18n", "serde", "serde_json", "strum", - "sys-locale", "thiserror 2.0.17", "tracing-subscriber", "ureq 3.3.0", @@ -6158,6 +6158,14 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "phichain-i18n" +version = "1.0.0-beta.6" +dependencies = [ + "rust-i18n", + "sys-locale", +] + [[package]] name = "phichain-renderer" version = "1.0.0-beta.6" @@ -6171,9 +6179,9 @@ dependencies = [ "phichain-assets", "phichain-chart", "phichain-game", + "phichain-i18n", "rust-i18n", "serde_json", - "sys-locale", "tempfile", ] diff --git a/phichain-converter/Cargo.toml b/phichain-converter/Cargo.toml index 5f06c1ab..c96b797b 100644 --- a/phichain-converter/Cargo.toml +++ b/phichain-converter/Cargo.toml @@ -11,7 +11,7 @@ phichain-format = { path = "../phichain-format" } rust-i18n = "=3.0.1" serde_json = "1.0.117" strum = { version = "0.27.1", features = ["derive"] } -sys-locale = "0.3.2" +phichain-i18n = { path = "../phichain-i18n" } tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1.0.228", features = ["derive"] } thiserror = "2" diff --git a/phichain-converter/src/i18n.rs b/phichain-converter/src/i18n.rs deleted file mode 100644 index 2f7dbce2..00000000 --- a/phichain-converter/src/i18n.rs +++ /dev/null @@ -1,39 +0,0 @@ -use rust_i18n::t; -use std::borrow::Cow; - -/// Normalize locale from system to rust-i18n format -fn normalize_locale(locale: &str) -> String { - // Remove encoding suffix and replace underscore - let base = locale.split('.').next().unwrap_or(locale).replace('_', "-"); - - // Map to available translation files - match base.as_str() { - "C" | "POSIX" => "en-US".to_string(), - // macOS verbose formats - "zh-Hans-CN" | "zh-Hans" | "zh-Hans-SG" => "zh-CN".to_string(), - // TODO: map these to zh-TW once a Traditional Chinese is supported. - "zh-TW" | "zh-HK" | "zh-MO" | "zh-Hant-CN" | "zh-Hant-TW" | "zh-Hant" | "zh-Hant-HK" - | "zh-Hant-MO" => "zh-CN".to_string(), - // Japanese (already matches filename ja-JP.yml) - // already normalized - _ => base, - } -} - -/// Get system locale with fallback -pub fn locale() -> String { - std::env::var("PHICHAIN_LANG") - .ok() - .map(|loc| normalize_locale(&loc)) - .or(sys_locale::get_locale().map(|loc| normalize_locale(&loc))) - .unwrap_or_else(|| "en-US".to_string()) -} - -// Leaks owned translations to produce `&'static str`. -// Acceptable here because the converter is a short-lived CLI process. -pub fn i18n_str(key: &'static str) -> &'static str { - match t!(key) { - Cow::Borrowed(s) => s, - Cow::Owned(s) => Box::leak(s.into_boxed_str()), - } -} diff --git a/phichain-converter/src/main.rs b/phichain-converter/src/main.rs index 0f774aef..8d9fc62b 100644 --- a/phichain-converter/src/main.rs +++ b/phichain-converter/src/main.rs @@ -1,10 +1,8 @@ mod error; -mod i18n; mod options; mod telemetry; use crate::error::{unwrap_infallible, ConvertError}; -use crate::i18n::{i18n_str, locale}; use crate::options::{ CliCommonOutputOptions, CliOfficialInputOptions, CliOfficialOutputOptions, CliRpeInputOptions, }; @@ -14,6 +12,7 @@ use phichain_chart::serialization::PhichainChart; use phichain_format::official::OfficialChart; use phichain_format::rpe::RpeChart; use phichain_format::{ChartFormat, CommonOutputOptions}; +use phichain_i18n::{i18n_str, locale}; use rust_i18n::t; use serde::Serialize; use std::io::Read; @@ -69,8 +68,8 @@ enum TelemetryCommand { #[derive(Parser, Debug, Clone)] #[command(name = "phichain-converter")] -#[command(about = i18n_str("cli.about"))] -#[command(after_help = i18n_str("cli.examples"))] +#[command(about = i18n_str!("cli.about"))] +#[command(after_help = i18n_str!("cli.examples"))] pub struct Args { #[arg(required = true, help = t!("cli.input").to_string())] input: PathBuf, @@ -84,25 +83,25 @@ pub struct Args { #[command(flatten)] #[command( - next_help_heading = i18n_str("cli.official_input.heading") + next_help_heading = i18n_str!("cli.official_input.heading") )] official_input_options: CliOfficialInputOptions, #[command(flatten)] #[command( - next_help_heading = i18n_str("cli.official_output.heading") + next_help_heading = i18n_str!("cli.official_output.heading") )] official_output_options: CliOfficialOutputOptions, #[command(flatten)] #[command( - next_help_heading = i18n_str("cli.rpe_input.heading") + next_help_heading = i18n_str!("cli.rpe_input.heading") )] rpe_input_options: CliRpeInputOptions, #[command(flatten)] #[command( - next_help_heading = i18n_str("cli.common_output.heading") + next_help_heading = i18n_str!("cli.common_output.heading") )] common_output_options: CliCommonOutputOptions, diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index 3d25dfee..baad6bde 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -21,4 +21,4 @@ hound = "3.5.1" tempfile = "3.10" indicatif = "0.17" rust-i18n = "=3.0.1" -sys-locale = "0.3.2" +phichain-i18n = { path = "../phichain-i18n" } diff --git a/phichain-renderer/src/args.rs b/phichain-renderer/src/args.rs index 3f8db6d8..280cc8da 100644 --- a/phichain-renderer/src/args.rs +++ b/phichain-renderer/src/args.rs @@ -1,13 +1,13 @@ -use crate::i18n::i18n_str; use bevy::prelude::Resource; use bevy::render::view::Msaa; use clap::{Parser, ValueEnum}; use phichain_game::GameConfig; +use phichain_i18n::i18n_str; use rust_i18n::t; use std::path::PathBuf; #[derive(Debug, Clone, Parser, Resource)] -#[command(about = i18n_str("cli.about"))] +#[command(about = i18n_str!("cli.about"))] pub struct Args { #[arg(help = t!("cli.args.path").to_string())] pub path: String, @@ -24,11 +24,11 @@ pub struct Args { pub respack: Option, #[command(flatten)] - #[command(next_help_heading = i18n_str("cli.video.heading"))] + #[command(next_help_heading = i18n_str!("cli.video.heading"))] pub video: VideoArgs, #[command(flatten)] - #[command(next_help_heading = i18n_str("cli.game.heading"))] + #[command(next_help_heading = i18n_str!("cli.game.heading"))] pub game: GameArgs, } diff --git a/phichain-renderer/src/i18n.rs b/phichain-renderer/src/i18n.rs deleted file mode 100644 index f3e0866c..00000000 --- a/phichain-renderer/src/i18n.rs +++ /dev/null @@ -1,39 +0,0 @@ -use rust_i18n::t; -use std::borrow::Cow; - -/// Normalize locale from system to rust-i18n format -fn normalize_locale(locale: &str) -> String { - // Remove encoding suffix and replace underscore - let base = locale.split('.').next().unwrap_or(locale).replace('_', "-"); - - // Map to available translation files - match base.as_str() { - "C" | "POSIX" => "en-US".to_string(), - // macOS verbose formats - "zh-Hans-CN" | "zh-Hans" | "zh-Hans-SG" => "zh-CN".to_string(), - // TODO: map these to zh-TW once a Traditional Chinese is supported. - "zh-TW" | "zh-HK" | "zh-MO" | "zh-Hant-CN" | "zh-Hant-TW" | "zh-Hant" | "zh-Hant-HK" - | "zh-Hant-MO" => "zh-CN".to_string(), - // Japanese (already matches filename ja-JP.yml) - // already normalized - _ => base, - } -} - -/// Get system locale with fallback -pub fn locale() -> String { - std::env::var("PHICHAIN_LANG") - .ok() - .map(|loc| normalize_locale(&loc)) - .or(sys_locale::get_locale().map(|loc| normalize_locale(&loc))) - .unwrap_or_else(|| "en-US".to_string()) -} - -// Leaks owned translations to produce `&'static str`. -// Acceptable here because the renderer is a short-lived CLI process. -pub fn i18n_str(key: &'static str) -> &'static str { - match t!(key) { - Cow::Borrowed(s) => s, - Cow::Owned(s) => Box::leak(s.into_boxed_str()), - } -} diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index 4fa73e16..107a9cab 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -12,13 +12,11 @@ mod args; mod audio; mod encoder; -mod i18n; mod respack; mod utils; use crate::args::Args; use crate::encoder::{on_frame_ready, Encoder}; -use crate::i18n::locale; use crate::respack::RespackPlugin; use bevy::app::ScheduleRunnerPlugin; use bevy::camera::RenderTarget; @@ -35,6 +33,7 @@ use phichain_assets::AssetsPlugin; use phichain_chart::project::Project; use phichain_game::audio::AudioDuration; use phichain_game::{GameConfig, GamePlugin, GameSet, GameViewport, Paused}; +use phichain_i18n::locale; use rust_i18n::t; use std::time::{Duration, Instant}; From 8f157892c0558138ef0f14d52425c43273847121 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 21:11:07 +0900 Subject: [PATCH 14/27] ftc --- phichain-renderer/src/encoder.rs | 10 +++++----- phichain-renderer/src/respack.rs | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index 7b4f09fc..a3f0b18a 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -1,10 +1,10 @@ //! ffmpeg subprocess management and per-frame writing. //! -//! A child `ffmpeg` reads raw RGBA bytes from stdin at a fixed size/framerate -//! and produces an h264 mp4. We drive `ChartTime` one video-frame at a time; -//! the game code renders a matching frame; Bevy's built-in `Readback` copies -//! that frame out of the GPU and fires `ReadbackComplete`, which we observe -//! here to pipe the bytes into ffmpeg. +//! A child `ffmpeg` reads raw RGBA bytes from stdin at a fixed size/framerate and produces an h264 mp4. +//! We drive `ChartTime` one video-frame at a time; +//! the game code renders a matching frame; +//! Bevy's built-in `Readback` copies that frame out of the GPU and fires `ReadbackComplete`, +//! which we observe here to pipe the bytes into ffmpeg. use bevy::app::AppExit; use bevy::prelude::*; diff --git a/phichain-renderer/src/respack.rs b/phichain-renderer/src/respack.rs index 8d0c62b0..4a12b613 100644 --- a/phichain-renderer/src/respack.rs +++ b/phichain-renderer/src/respack.rs @@ -35,9 +35,6 @@ impl Plugin for RespackPlugin { ); std::process::exit(1); } - info!( - "{}", - t!("cli.status.loaded_respack", path = path.display()) - ); + info!("{}", t!("cli.status.loaded_respack", path = path.display())); } } From 922f1e21c3e3dcd1fc687b62923c320dd951a54d Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 21:31:27 +0900 Subject: [PATCH 15/27] added ffmpeg check --- phichain-renderer/locales/en-US.yml | 1 + phichain-renderer/locales/ja-JP.yml | 1 + phichain-renderer/locales/zh-CN.yml | 1 + phichain-renderer/src/encoder.rs | 17 +++++++++++++++++ phichain-renderer/src/main.rs | 8 +++++++- 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/phichain-renderer/locales/en-US.yml b/phichain-renderer/locales/en-US.yml index 606001da..4ec729b0 100644 --- a/phichain-renderer/locales/en-US.yml +++ b/phichain-renderer/locales/en-US.yml @@ -37,3 +37,4 @@ cli: error: load_respack_failed: "failed to load respack %{path}: %{error}" apply_respack_failed: "failed to apply respack %{path}: %{error}" + ffmpeg_missing: "Failed to run ffmpeg; make sure it is installed and on your PATH: %{error}" diff --git a/phichain-renderer/locales/ja-JP.yml b/phichain-renderer/locales/ja-JP.yml index 793a0572..6f1941cb 100644 --- a/phichain-renderer/locales/ja-JP.yml +++ b/phichain-renderer/locales/ja-JP.yml @@ -37,3 +37,4 @@ cli: error: load_respack_failed: 'リソースパックの読み込みに失敗 %{path}: %{error}' apply_respack_failed: 'リソースパックの適用に失敗 %{path}: %{error}' + ffmpeg_missing: 'ffmpeg を起動できません。インストールされ PATH に含まれていることを確認してください: %{error}' diff --git a/phichain-renderer/locales/zh-CN.yml b/phichain-renderer/locales/zh-CN.yml index 11e058fa..73bcca91 100644 --- a/phichain-renderer/locales/zh-CN.yml +++ b/phichain-renderer/locales/zh-CN.yml @@ -37,3 +37,4 @@ cli: error: load_respack_failed: '加载资源包失败 %{path}: %{error}' apply_respack_failed: '应用资源包失败 %{path}: %{error}' + ffmpeg_missing: '无法运行 ffmpeg,请确认已安装并位于 PATH 中: %{error}' diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index a3f0b18a..22a11554 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -25,6 +25,23 @@ use crate::args::{Args, Codec}; /// often transparent or blocky and would show up as garbage. const WARMUP_FRAMES: u32 = 40; +pub fn ensure_ffmpeg_available() -> Result<(), std::io::Error> { + let status = Command::new("ffmpeg") + .arg("-version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + + if !status.success() { + return Err(std::io::Error::other(format!( + "ffmpeg -version exited with {status}" + ))); + } + + Ok(()) +} + #[derive(Resource)] pub struct Encoder { ffmpeg: Child, diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index 107a9cab..2893a508 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -16,7 +16,7 @@ mod respack; mod utils; use crate::args::Args; -use crate::encoder::{on_frame_ready, Encoder}; +use crate::encoder::{ensure_ffmpeg_available, on_frame_ready, Encoder}; use crate::respack::RespackPlugin; use bevy::app::ScheduleRunnerPlugin; use bevy::camera::RenderTarget; @@ -44,6 +44,12 @@ fn main() { rust_i18n::set_locale(&locale()); let args = Args::parse(); + + if let Err(err) = ensure_ffmpeg_available() { + eprintln!("{}", t!("cli.error.ffmpeg_missing", error = err)); + std::process::exit(1); + } + let started = Instant::now(); App::new() From 83866279e4008cbf3a8d8f50816a9dc5d890137d Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 21:33:08 +0900 Subject: [PATCH 16/27] added ffmpeg help docs (wip) --- phichain-renderer/locales/en-US.yml | 2 +- phichain-renderer/locales/ja-JP.yml | 2 +- phichain-renderer/locales/zh-CN.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/phichain-renderer/locales/en-US.yml b/phichain-renderer/locales/en-US.yml index 4ec729b0..6caaae0a 100644 --- a/phichain-renderer/locales/en-US.yml +++ b/phichain-renderer/locales/en-US.yml @@ -37,4 +37,4 @@ cli: error: load_respack_failed: "failed to load respack %{path}: %{error}" apply_respack_failed: "failed to apply respack %{path}: %{error}" - ffmpeg_missing: "Failed to run ffmpeg; make sure it is installed and on your PATH: %{error}" + ffmpeg_missing: "Failed to run ffmpeg; make sure it is installed and on your PATH (see https://phicha.in/ffmpeg): %{error}" diff --git a/phichain-renderer/locales/ja-JP.yml b/phichain-renderer/locales/ja-JP.yml index 6f1941cb..2fbd7acd 100644 --- a/phichain-renderer/locales/ja-JP.yml +++ b/phichain-renderer/locales/ja-JP.yml @@ -37,4 +37,4 @@ cli: error: load_respack_failed: 'リソースパックの読み込みに失敗 %{path}: %{error}' apply_respack_failed: 'リソースパックの適用に失敗 %{path}: %{error}' - ffmpeg_missing: 'ffmpeg を起動できません。インストールされ PATH に含まれていることを確認してください: %{error}' + ffmpeg_missing: 'ffmpeg を起動できません。インストールされ PATH に含まれていることを確認してください (詳しくは https://phicha.in/ffmpeg): %{error}' diff --git a/phichain-renderer/locales/zh-CN.yml b/phichain-renderer/locales/zh-CN.yml index 73bcca91..817ecb27 100644 --- a/phichain-renderer/locales/zh-CN.yml +++ b/phichain-renderer/locales/zh-CN.yml @@ -37,4 +37,4 @@ cli: error: load_respack_failed: '加载资源包失败 %{path}: %{error}' apply_respack_failed: '应用资源包失败 %{path}: %{error}' - ffmpeg_missing: '无法运行 ffmpeg,请确认已安装并位于 PATH 中: %{error}' + ffmpeg_missing: '无法运行 ffmpeg,请确认已安装并位于 PATH 中 (详见 https://phicha.in/ffmpeg): %{error}' From 7f20f9c33e9fe445bb507ad647ff4615f70df8b1 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 21:38:43 +0900 Subject: [PATCH 17/27] moved ChartMetrics to phichain-chart --- phichain-chart/src/lib.rs | 1 + phichain-chart/src/metrics.rs | 25 +++++++++++++++++++++++++ phichain-converter/src/main.rs | 28 ++-------------------------- 3 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 phichain-chart/src/metrics.rs diff --git a/phichain-chart/src/lib.rs b/phichain-chart/src/lib.rs index ab68c799..ecc7d202 100644 --- a/phichain-chart/src/lib.rs +++ b/phichain-chart/src/lib.rs @@ -5,6 +5,7 @@ pub mod curve_note_track; pub mod easing; pub mod event; pub mod line; +pub mod metrics; pub mod migration; pub mod note; pub mod offset; diff --git a/phichain-chart/src/metrics.rs b/phichain-chart/src/metrics.rs new file mode 100644 index 00000000..cf90e189 --- /dev/null +++ b/phichain-chart/src/metrics.rs @@ -0,0 +1,25 @@ +use crate::serialization::SerializedLine; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct ChartMetrics { + pub lines: usize, + pub notes: usize, + pub events: usize, +} + +impl ChartMetrics { + pub fn collect(lines: &[SerializedLine]) -> Self { + let mut metrics = Self::default(); + for line in lines { + metrics.lines += 1; + metrics.notes += line.notes.len(); + metrics.events += line.events.len(); + let child = Self::collect(&line.children); + metrics.lines += child.lines; + metrics.notes += child.notes; + metrics.events += child.events; + } + metrics + } +} diff --git a/phichain-converter/src/main.rs b/phichain-converter/src/main.rs index 8d9fc62b..37388dad 100644 --- a/phichain-converter/src/main.rs +++ b/phichain-converter/src/main.rs @@ -8,6 +8,7 @@ use crate::options::{ }; use clap::{Parser, Subcommand, ValueEnum}; use owo_colors::OwoColorize; +use phichain_chart::metrics::ChartMetrics; use phichain_chart::serialization::PhichainChart; use phichain_format::official::OfficialChart; use phichain_format::rpe::RpeChart; @@ -148,31 +149,6 @@ fn read_input(path: &std::path::Path) -> Result { Ok(std::fs::read_to_string(path)?) } -#[derive(Serialize)] -struct ChartMetrics { - lines: usize, - notes: usize, - events: usize, -} - -fn collect_chart_metrics(lines: &[phichain_chart::serialization::SerializedLine]) -> ChartMetrics { - let mut metrics = ChartMetrics { - lines: 0, - notes: 0, - events: 0, - }; - for line in lines { - metrics.lines += 1; - metrics.notes += line.notes.len(); - metrics.events += line.events.len(); - let child = collect_chart_metrics(&line.children); - metrics.lines += child.lines; - metrics.notes += child.notes; - metrics.events += child.events; - } - metrics -} - #[derive(Serialize)] struct ConvertTelemetry { locale: String, @@ -190,7 +166,7 @@ struct ConvertTelemetry { impl Chart { fn metrics(&self) -> ChartMetrics { match self { - Chart::Phichain(c) => collect_chart_metrics(&c.lines), + Chart::Phichain(c) => ChartMetrics::collect(&c.lines), Chart::Official(c) => ChartMetrics { lines: c.lines.len(), notes: c From 0cc485e731feb7187e3d52cc74c1b4bb18a9d970 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 22:06:19 +0900 Subject: [PATCH 18/27] prepare for telemetry --- Cargo.lock | 3 +++ phichain-renderer/Cargo.toml | 3 +++ phichain-renderer/locales/en-US.yml | 2 ++ phichain-renderer/locales/ja-JP.yml | 2 ++ phichain-renderer/locales/zh-CN.yml | 2 ++ phichain-renderer/src/args.rs | 13 +++++++++++-- 6 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e57ef6af..9bf11e3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6180,9 +6180,12 @@ dependencies = [ "phichain-chart", "phichain-game", "phichain-i18n", + "phichain-telemetry", "rust-i18n", + "serde", "serde_json", "tempfile", + "ureq 3.3.0", ] [[package]] diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index baad6bde..fe18b811 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -22,3 +22,6 @@ tempfile = "3.10" indicatif = "0.17" rust-i18n = "=3.0.1" phichain-i18n = { path = "../phichain-i18n" } +phichain-telemetry = { path = "../phichain-telemetry" } +serde = { version = "1.0.228", features = ["derive"] } +ureq = "3.3.0" diff --git a/phichain-renderer/locales/en-US.yml b/phichain-renderer/locales/en-US.yml index 6caaae0a..60e63791 100644 --- a/phichain-renderer/locales/en-US.yml +++ b/phichain-renderer/locales/en-US.yml @@ -19,6 +19,8 @@ cli: crf: "Constant Rate Factor: 0 (lossless) to 51 (worst). 18 is \"visually lossless\". For hardware encoders this is mapped to the encoder's native quality knob. Mutually exclusive with --bitrate." bitrate: "Target bitrate (e.g. \"8M\", \"6000k\"). Mutually exclusive with --crf." + no_telemetry: Disable telemetry reporting + game: heading: Game Options note_scale: The scale factor for notes diff --git a/phichain-renderer/locales/ja-JP.yml b/phichain-renderer/locales/ja-JP.yml index 2fbd7acd..50c1d4ba 100644 --- a/phichain-renderer/locales/ja-JP.yml +++ b/phichain-renderer/locales/ja-JP.yml @@ -19,6 +19,8 @@ cli: crf: '固定品質係数:0 (ロスレス) から 51 (最低)、18 は視覚的にロスレス。ハードウェアエンコーダーの場合は対応するネイティブ品質パラメータにマッピングされます。--bitrate と排他' bitrate: 'ターゲットビットレート (例: "8M"、"6000k")。--crf と排他' + no_telemetry: テレメトリ送信を無効にする + game: heading: 'ゲームオプション' note_scale: ノートのスケール係数 diff --git a/phichain-renderer/locales/zh-CN.yml b/phichain-renderer/locales/zh-CN.yml index 817ecb27..9f1f1dae 100644 --- a/phichain-renderer/locales/zh-CN.yml +++ b/phichain-renderer/locales/zh-CN.yml @@ -19,6 +19,8 @@ cli: crf: '固定质量因子:0 (无损) 到 51 (最差),18 为视觉无损。对于硬件编码器,会映射到对应的原生质量参数。与 --bitrate 互斥' bitrate: '目标码率 (例如 "8M"、"6000k")。与 --crf 互斥' + no_telemetry: 禁用遥测上报 + game: heading: '游戏选项' note_scale: 音符的缩放系数 diff --git a/phichain-renderer/src/args.rs b/phichain-renderer/src/args.rs index 280cc8da..f71dd573 100644 --- a/phichain-renderer/src/args.rs +++ b/phichain-renderer/src/args.rs @@ -4,6 +4,7 @@ use clap::{Parser, ValueEnum}; use phichain_game::GameConfig; use phichain_i18n::i18n_str; use rust_i18n::t; +use serde::Serialize; use std::path::PathBuf; #[derive(Debug, Clone, Parser, Resource)] @@ -30,6 +31,9 @@ pub struct Args { #[command(flatten)] #[command(next_help_heading = i18n_str!("cli.game.heading"))] pub game: GameArgs, + + #[arg(long, help = t!("cli.no_telemetry").to_string())] + pub no_telemetry: bool, } #[derive(Debug, Clone, Parser)] @@ -79,12 +83,15 @@ pub struct VideoArgs { pub bitrate: Option, } -#[derive(Debug, Clone, Copy, ValueEnum)] +#[derive(Debug, Clone, Copy, ValueEnum, Serialize)] +#[serde(rename_all = "snake_case")] pub enum MsaaLevel { Off, #[value(name = "2")] + #[serde(rename = "2")] Two, #[value(name = "4")] + #[serde(rename = "4")] Four, } @@ -98,11 +105,13 @@ impl MsaaLevel { } } -#[derive(Debug, Clone, Copy, ValueEnum)] +#[derive(Debug, Clone, Copy, ValueEnum, Serialize)] pub enum Codec { #[value(name = "h264")] + #[serde(rename = "h264")] H264, #[value(name = "h265")] + #[serde(rename = "h265")] H265, } From fc8b92fbd6d6af016d37cf44bb6165b6df24a7f3 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 22:14:15 +0900 Subject: [PATCH 19/27] moved flush and telemetry subcommand entry to phichain-telemetry --- Cargo.lock | 3 +- phichain-converter/Cargo.toml | 1 - phichain-converter/src/main.rs | 10 ++- phichain-converter/src/telemetry.rs | 93 --------------------- phichain-renderer/Cargo.toml | 1 - phichain-telemetry/Cargo.toml | 1 + phichain-telemetry/src/lib.rs | 3 + phichain-telemetry/src/report.rs | 122 ++++++++++++++++++++++++++++ 8 files changed, 134 insertions(+), 100 deletions(-) delete mode 100644 phichain-converter/src/telemetry.rs create mode 100644 phichain-telemetry/src/report.rs diff --git a/Cargo.lock b/Cargo.lock index 9bf11e3c..b7e3e2e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6121,7 +6121,6 @@ dependencies = [ "strum", "thiserror 2.0.17", "tracing-subscriber", - "ureq 3.3.0", ] [[package]] @@ -6185,7 +6184,6 @@ dependencies = [ "serde", "serde_json", "tempfile", - "ureq 3.3.0", ] [[package]] @@ -6199,6 +6197,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "ureq 3.3.0", "uuid", ] diff --git a/phichain-converter/Cargo.toml b/phichain-converter/Cargo.toml index c96b797b..ee335674 100644 --- a/phichain-converter/Cargo.toml +++ b/phichain-converter/Cargo.toml @@ -17,4 +17,3 @@ serde = { version = "1.0.228", features = ["derive"] } thiserror = "2" owo-colors = "4" phichain-telemetry = { path = "../phichain-telemetry" } -ureq = "3.3.0" diff --git a/phichain-converter/src/main.rs b/phichain-converter/src/main.rs index 37388dad..9516bc9c 100644 --- a/phichain-converter/src/main.rs +++ b/phichain-converter/src/main.rs @@ -1,6 +1,5 @@ mod error; mod options; -mod telemetry; use crate::error::{unwrap_infallible, ConvertError}; use crate::options::{ @@ -290,7 +289,7 @@ fn main() { let cli = TelemetryCli::parse_from(std::env::args().skip(1)); match cli.command { TelemetryCommand::Flush { path } => { - let _ = telemetry::flush(path); + let _ = phichain_telemetry::flush(path); } } return; @@ -329,7 +328,12 @@ fn main() { } if !no_telemetry && !phichain_telemetry::env::telemetry_disabled() { - let _ = telemetry::track( + let reporter = phichain_telemetry::Reporter::new( + "phichain-converter", + env!("CARGO_PKG_VERSION"), + cfg!(debug_assertions), + ); + let _ = reporter.track( "phichain.converter.convert", serde_json::to_value(&meta).unwrap(), ); diff --git a/phichain-converter/src/telemetry.rs b/phichain-converter/src/telemetry.rs deleted file mode 100644 index 7290d687..00000000 --- a/phichain-converter/src/telemetry.rs +++ /dev/null @@ -1,93 +0,0 @@ -use phichain_telemetry::payload::{PhichainMeta, TelemetryPayload}; -use serde_json::Value; -use std::fs::File; -use std::path::PathBuf; -use std::process::{Command, Stdio}; - -pub fn track(event_type: &str, metadata: Value) -> Result<(), std::io::Error> { - let payload = TelemetryPayload::builder() - .reporter("phichain-converter") - .event_type(event_type) - .maybe_device_id(phichain_telemetry::device::get_device_id()) - .phichain(PhichainMeta::new( - env!("CARGO_PKG_VERSION"), - cfg!(debug_assertions), - )) - .metadata(metadata) - .build(); - - if phichain_telemetry::env::telemetry_debug() { - eprintln!("[telemetry] {}", serde_json::to_string_pretty(&payload)?); - return Ok(()); - } - - let pid = std::process::id(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let path = std::env::temp_dir().join(format!("phichain-telemetry-{pid}-{timestamp}.json")); - let file = File::create(&path)?; - serde_json::to_writer(file, &payload)?; - - let current_exe = std::env::current_exe()?; - Command::new(current_exe) - .arg("telemetry") - .arg("flush") - .arg(path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn()?; - - Ok(()) -} - -const TELEMETRY_FILE_PREFIX: &str = "phichain-telemetry-"; - -/// Called by `phichain-converter telemetry flush ` -pub fn flush(file: PathBuf) -> Result<(), std::io::Error> { - // Validate: file must be inside the system temp directory - let file = file.canonicalize()?; - let temp_dir = std::env::temp_dir().canonicalize()?; - if !file.starts_with(&temp_dir) { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "telemetry file must be in temp directory", - )); - } - - // Validate: filename must match expected prefix - let file_name = file - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); - if !file_name.starts_with(TELEMETRY_FILE_PREFIX) || !file_name.ends_with(".json") { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "unexpected telemetry file name", - )); - } - - // Validate: content must be valid JSON - let content = std::fs::read(&file)?; - let payload: Value = serde_json::from_slice(&content).map_err(|_| { - let _ = std::fs::remove_file(&file); - std::io::Error::new( - std::io::ErrorKind::InvalidData, - "telemetry file contains invalid JSON", - ) - })?; - - // wrap in array to match the batch format - let batch = serde_json::to_vec(&[payload])?; - - let agent = ureq::Agent::new_with_defaults(); - let _ = agent - .post(phichain_telemetry::TELEMETRY_URL) - .header("Content-Type", "application/json") - .send(&*batch); - - let _ = std::fs::remove_file(&file); - Ok(()) -} diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index fe18b811..6971548b 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -24,4 +24,3 @@ rust-i18n = "=3.0.1" phichain-i18n = { path = "../phichain-i18n" } phichain-telemetry = { path = "../phichain-telemetry" } serde = { version = "1.0.228", features = ["derive"] } -ureq = "3.3.0" diff --git a/phichain-telemetry/Cargo.toml b/phichain-telemetry/Cargo.toml index 2edfa23b..6086e8b6 100644 --- a/phichain-telemetry/Cargo.toml +++ b/phichain-telemetry/Cargo.toml @@ -11,4 +11,5 @@ os_info = "3.14.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.11.0" +ureq = "3.3.0" uuid = { version = "1.23.0", features = ["v4"] } diff --git a/phichain-telemetry/src/lib.rs b/phichain-telemetry/src/lib.rs index 8b1eb863..0d87a917 100644 --- a/phichain-telemetry/src/lib.rs +++ b/phichain-telemetry/src/lib.rs @@ -12,6 +12,9 @@ pub mod device; pub mod env; pub mod payload; +pub mod report; + +pub use report::{flush, Reporter}; /// The telemetry reporting endpoint. pub const TELEMETRY_URL: &str = "https://telemetry.phichain.rs/report"; diff --git a/phichain-telemetry/src/report.rs b/phichain-telemetry/src/report.rs new file mode 100644 index 00000000..bf64a533 --- /dev/null +++ b/phichain-telemetry/src/report.rs @@ -0,0 +1,122 @@ +//! Fire-and-forget telemetry reporting for CLIs. +//! +//! [`Reporter::track`] writes a payload to a temp file and spawns the current +//! executable with `telemetry flush ` so the HTTP POST happens outside +//! the reporter's main process. Each phichain application must therefore +//! route `argv[1] == "telemetry"` to [`flush`] before its normal startup. + +use crate::payload::{PhichainMeta, TelemetryPayload}; +use serde_json::Value; +use std::fs::File; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +const TELEMETRY_FILE_PREFIX: &str = "phichain-telemetry-"; + +/// Identifies the reporting application and is baked into every payload. +pub struct Reporter { + name: &'static str, + version: &'static str, + debug: bool, +} + +impl Reporter { + /// `debug` should be `cfg!(debug_assertions)` from the caller's crate + pub fn new(name: &'static str, version: &'static str, debug: bool) -> Self { + Self { + name, + version, + debug, + } + } + + /// Serialize a payload and hand it off to a flush subprocess. + /// + /// With `PHICHAIN_TELEMETRY_DEBUG` set, prints to stderr and returns + /// instead of spawning anything. + pub fn track(&self, event_type: &str, metadata: Value) -> std::io::Result<()> { + let payload = TelemetryPayload::builder() + .reporter(self.name) + .event_type(event_type) + .maybe_device_id(crate::device::get_device_id()) + .phichain(PhichainMeta::new(self.version, self.debug)) + .metadata(metadata) + .build(); + + if crate::env::telemetry_debug() { + eprintln!("[telemetry] {}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + + let pid = std::process::id(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let path = std::env::temp_dir() + .join(format!("{TELEMETRY_FILE_PREFIX}{pid}-{timestamp}.json")); + let file = File::create(&path)?; + serde_json::to_writer(file, &payload)?; + + let current_exe = std::env::current_exe()?; + Command::new(current_exe) + .arg("telemetry") + .arg("flush") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(()) + } +} + +/// Entry point for the ` telemetry flush ` subcommand. +/// +/// Validates that `file` sits in the system temp directory with the +/// expected prefix, POSTs its contents to the telemetry endpoint, then +/// removes the file. Errors are swallowed by the caller by convention so +/// an unreachable server never breaks the user's flow. +pub fn flush(file: PathBuf) -> std::io::Result<()> { + let file = file.canonicalize()?; + let temp_dir = std::env::temp_dir().canonicalize()?; + if !file.starts_with(&temp_dir) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "telemetry file must be in temp directory", + )); + } + + let file_name = file + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if !file_name.starts_with(TELEMETRY_FILE_PREFIX) || !file_name.ends_with(".json") { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "unexpected telemetry file name", + )); + } + + let content = std::fs::read(&file)?; + let payload: Value = serde_json::from_slice(&content).map_err(|_| { + let _ = std::fs::remove_file(&file); + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "telemetry file contains invalid JSON", + ) + })?; + + // Wrap in array to match the batch format the endpoint expects. + let batch = serde_json::to_vec(&[payload])?; + + let agent = ureq::Agent::new_with_defaults(); + let _ = agent + .post(crate::TELEMETRY_URL) + .header("Content-Type", "application/json") + .send(&*batch); + + let _ = std::fs::remove_file(&file); + Ok(()) +} From ea74aaf0b60864b48a204cc11bcb1ef92bf7e606 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 22:19:23 +0900 Subject: [PATCH 20/27] moved telemetry subcommand to phichain-telemetry --- Cargo.lock | 1 + phichain-converter/src/main.rs | 24 ++---------------------- phichain-telemetry/Cargo.toml | 1 + phichain-telemetry/src/lib.rs | 2 +- phichain-telemetry/src/report.rs | 29 +++++++++++++++++++++++++++++ 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7e3e2e8..53d5a9e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6192,6 +6192,7 @@ version = "1.0.0-beta.6" dependencies = [ "bon", "chrono", + "clap", "machine-uid", "os_info", "serde", diff --git a/phichain-converter/src/main.rs b/phichain-converter/src/main.rs index 9516bc9c..7bf1debd 100644 --- a/phichain-converter/src/main.rs +++ b/phichain-converter/src/main.rs @@ -5,7 +5,7 @@ use crate::error::{unwrap_infallible, ConvertError}; use crate::options::{ CliCommonOutputOptions, CliOfficialInputOptions, CliOfficialOutputOptions, CliRpeInputOptions, }; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{Parser, ValueEnum}; use owo_colors::OwoColorize; use phichain_chart::metrics::ChartMetrics; use phichain_chart::serialization::PhichainChart; @@ -53,19 +53,6 @@ enum Format { Rpe, } -#[derive(Parser, Debug, Clone)] -#[command(name = "phichain-converter telemetry")] -#[command(hide = true)] -struct TelemetryCli { - #[command(subcommand)] - command: TelemetryCommand, -} - -#[derive(Subcommand, Debug, Clone)] -enum TelemetryCommand { - Flush { path: PathBuf }, -} - #[derive(Parser, Debug, Clone)] #[command(name = "phichain-converter")] #[command(about = i18n_str!("cli.about"))] @@ -284,14 +271,7 @@ fn convert(args: Args, meta: &mut ConvertTelemetry) -> Result<(), ConvertError> } fn main() { - // Route `phichain-converter telemetry ` before normal arg parsing - if std::env::args().nth(1).as_deref() == Some("telemetry") { - let cli = TelemetryCli::parse_from(std::env::args().skip(1)); - match cli.command { - TelemetryCommand::Flush { path } => { - let _ = phichain_telemetry::flush(path); - } - } + if phichain_telemetry::handle_subcommand() { return; } diff --git a/phichain-telemetry/Cargo.toml b/phichain-telemetry/Cargo.toml index 6086e8b6..0355238c 100644 --- a/phichain-telemetry/Cargo.toml +++ b/phichain-telemetry/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] bon = "3.9.1" chrono = "0.4.44" +clap = { version = "4.5.15", features = ["derive"] } machine-uid = "0.5.4" os_info = "3.14.0" serde = { version = "1.0.228", features = ["derive"] } diff --git a/phichain-telemetry/src/lib.rs b/phichain-telemetry/src/lib.rs index 0d87a917..2f49b75a 100644 --- a/phichain-telemetry/src/lib.rs +++ b/phichain-telemetry/src/lib.rs @@ -14,7 +14,7 @@ pub mod env; pub mod payload; pub mod report; -pub use report::{flush, Reporter}; +pub use report::{flush, handle_subcommand, Reporter}; /// The telemetry reporting endpoint. pub const TELEMETRY_URL: &str = "https://telemetry.phichain.rs/report"; diff --git a/phichain-telemetry/src/report.rs b/phichain-telemetry/src/report.rs index bf64a533..7054aee8 100644 --- a/phichain-telemetry/src/report.rs +++ b/phichain-telemetry/src/report.rs @@ -6,6 +6,7 @@ //! route `argv[1] == "telemetry"` to [`flush`] before its normal startup. use crate::payload::{PhichainMeta, TelemetryPayload}; +use clap::{Parser, Subcommand}; use serde_json::Value; use std::fs::File; use std::path::PathBuf; @@ -13,6 +14,34 @@ use std::process::{Command, Stdio}; const TELEMETRY_FILE_PREFIX: &str = "phichain-telemetry-"; +#[derive(Parser, Debug, Clone)] +#[command(name = "telemetry", hide = true)] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(Subcommand, Debug, Clone)] +enum Cmd { + Flush { path: PathBuf }, +} + +/// Early-dispatch the hidden ` telemetry <...>` subcommand. +/// +/// Returns `true` when the current invocation is a telemetry subcommand。 +pub fn handle_subcommand() -> bool { + if std::env::args().nth(1).as_deref() != Some("telemetry") { + return false; + } + let cli = Cli::parse_from(std::env::args().skip(1)); + match cli.command { + Cmd::Flush { path } => { + let _ = flush(path); + } + } + true +} + /// Identifies the reporting application and is baked into every payload. pub struct Reporter { name: &'static str, From 4ea63ca6999dcd7af7a5bee0f2ea57321cd36763 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 22:34:48 +0900 Subject: [PATCH 21/27] fmt --- phichain-telemetry/src/lib.rs | 2 +- phichain-telemetry/src/report.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/phichain-telemetry/src/lib.rs b/phichain-telemetry/src/lib.rs index 2f49b75a..0c155a53 100644 --- a/phichain-telemetry/src/lib.rs +++ b/phichain-telemetry/src/lib.rs @@ -14,7 +14,7 @@ pub mod env; pub mod payload; pub mod report; -pub use report::{flush, handle_subcommand, Reporter}; +pub use report::{Reporter, flush, handle_subcommand}; /// The telemetry reporting endpoint. pub const TELEMETRY_URL: &str = "https://telemetry.phichain.rs/report"; diff --git a/phichain-telemetry/src/report.rs b/phichain-telemetry/src/report.rs index 7054aee8..ba2799ea 100644 --- a/phichain-telemetry/src/report.rs +++ b/phichain-telemetry/src/report.rs @@ -82,8 +82,8 @@ impl Reporter { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis(); - let path = std::env::temp_dir() - .join(format!("{TELEMETRY_FILE_PREFIX}{pid}-{timestamp}.json")); + let path = + std::env::temp_dir().join(format!("{TELEMETRY_FILE_PREFIX}{pid}-{timestamp}.json")); let file = File::create(&path)?; serde_json::to_writer(file, &payload)?; From f80339904d2d1f77955ec675957ecc955ae285d2 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Wed, 22 Apr 2026 22:52:25 +0900 Subject: [PATCH 22/27] added telemetry support for phichain-renderer --- phichain-renderer/src/audio.rs | 4 +- phichain-renderer/src/encoder.rs | 16 +++- phichain-renderer/src/main.rs | 28 +++++-- phichain-renderer/src/telemetry.rs | 122 +++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 phichain-renderer/src/telemetry.rs diff --git a/phichain-renderer/src/audio.rs b/phichain-renderer/src/audio.rs index db651362..771e19c9 100644 --- a/phichain-renderer/src/audio.rs +++ b/phichain-renderer/src/audio.rs @@ -24,6 +24,7 @@ const CHANNELS: u16 = 2; /// Returned tempfile must outlive the ffmpeg process that reads it. pub fn render_audio_track( project: &Project, + chart: &PhichainChart, respack: Option<&Path>, from: f32, to: f32, @@ -32,7 +33,6 @@ pub fn render_audio_track( let started = Instant::now(); - let chart = read_chart(project).context("read chart")?; let offset_secs = chart.offset.0 / 1000.0; let mut notes = NoteTimes::default(); collect_notes(&chart.lines, &chart.bpm_list, from, to, &mut notes); @@ -82,7 +82,7 @@ pub fn render_audio_track( Ok(temp) } -fn read_chart(project: &Project) -> Result { +pub fn read_chart(project: &Project) -> Result { let file = File::open(project.path.chart_path())?; let raw: Value = serde_json::from_reader(file)?; let migrated = migrate(&raw)?; diff --git a/phichain-renderer/src/encoder.rs b/phichain-renderer/src/encoder.rs index 22a11554..cd9aef8f 100644 --- a/phichain-renderer/src/encoder.rs +++ b/phichain-renderer/src/encoder.rs @@ -120,13 +120,14 @@ impl Encoder { } } -/// Observer fired by Bevy's `GpuReadbackPlugin` each time a frame has been -/// copied back from the GPU. +/// Observer fired by Bevy's `GpuReadbackPlugin` each time a frame has been copied back from the GPU. pub fn on_frame_ready( event: On, mut enc: ResMut, mut chart_time: ResMut, mut exit: MessageWriter, + + telemetry: Option>, ) { chart_time.0 = enc.next_chart_time(); @@ -154,6 +155,15 @@ pub fn on_frame_ready( let elapsed = enc.start.elapsed().as_secs_f32(); let avg_fps = enc.frames_written as f32 / elapsed; let realtime = avg_fps / enc.fps as f32; + let frames = enc.frames_written; + if let Some(telemetry) = telemetry { + telemetry.update(|m| { + m.duration_ms = (elapsed * 1000.0) as u64; + m.frames_written = frames; + m.avg_fps = avg_fps; + m.realtime_factor = realtime; + }); + } info!( "{}", t!( @@ -175,7 +185,7 @@ pub fn on_frame_ready( /// Pick the ffmpeg encoder name for a `(codec, hardware-accel)` combination. /// Hardware encoder selection is best-effort per-platform. -fn pick_encoder(codec: Codec, hwaccel: bool) -> &'static str { +pub(crate) fn pick_encoder(codec: Codec, hwaccel: bool) -> &'static str { match (codec, hwaccel) { (Codec::H264, false) => "libx264", (Codec::H265, false) => "libx265", diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index 2893a508..bcf843b0 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -13,6 +13,7 @@ mod args; mod audio; mod encoder; mod respack; +mod telemetry; mod utils; use crate::args::Args; @@ -30,6 +31,7 @@ use bevy::winit::WinitPlugin; use bevy_kira_audio::AudioPlugin; use clap::Parser; use phichain_assets::AssetsPlugin; +use phichain_chart::metrics::ChartMetrics; use phichain_chart::project::Project; use phichain_game::audio::AudioDuration; use phichain_game::{GameConfig, GamePlugin, GameSet, GameViewport, Paused}; @@ -40,6 +42,10 @@ use std::time::{Duration, Instant}; rust_i18n::i18n!("locales", fallback = "en-US"); fn main() { + if phichain_telemetry::handle_subcommand() { + return; + } + phichain_assets::setup_assets(); rust_i18n::set_locale(&locale()); @@ -50,12 +56,15 @@ fn main() { std::process::exit(1); } + let no_telemetry = args.no_telemetry; + let telemetry = telemetry::Shared::new(&args); let started = Instant::now(); - App::new() + let exit = App::new() .configure_sets(Update, GameSet) .insert_resource(ClearColor(Color::srgb_u8(0, 0, 0))) .insert_resource(args) + .insert_resource(telemetry.clone()) .add_plugins( DefaultPlugins .set(WindowPlugin { @@ -64,8 +73,6 @@ fn main() { ..default() }) .set(LogPlugin { - // Silence the shutdown-time readback-channel warning; we - // intentionally exit with a few readbacks still in flight. filter: "warn,phichain_renderer=info,bevy_render::gpu_readback=error" .to_string(), level: bevy::log::Level::DEBUG, @@ -74,7 +81,7 @@ fn main() { // WinitPlugin will panic in environments without a display server. .disable::(), ) - // Offline rendering: run the loop as fast as possible. + // offline rendering: run the loop as fast as possible. .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::ZERO)) .add_plugins(AudioPlugin) .add_plugins(AssetsPlugin) @@ -90,6 +97,10 @@ fn main() { elapsed = format!("{:.2}", started.elapsed().as_secs_f64()) ) ); + + if !no_telemetry { + telemetry::report(&telemetry, exit); + } } fn setup( @@ -99,6 +110,7 @@ fn setup( mut paused: ResMut, mut game_config: ResMut, args: Res, + telemetry: Res, ) { let project = Project::open(args.path.clone().into()).expect("failed to open project"); let music_duration = utils::audio_duration( @@ -109,6 +121,12 @@ fn setup( ) .expect("failed to read audio duration"); + let chart = audio::read_chart(&project).expect("failed to read chart"); + telemetry.update(|m| { + m.music_duration_sec = Some(music_duration); + m.chart = Some(ChartMetrics::collect(&chart.lines)); + }); + // Offscreen GPU texture the camera renders into; Readback copies it out each frame. let mut target = Image::new_target_texture( args.video.width, @@ -152,7 +170,7 @@ fn setup( // Prepare audio before spawning the encoder. // the encoder consumes the WAV as its second input, so it must exist on disk at spawn time. - let audio = audio::render_audio_track(&project, args.respack.as_deref(), from, to) + let audio = audio::render_audio_track(&project, &chart, args.respack.as_deref(), from, to) .expect("failed to render audio track"); commands.insert_resource(Encoder::spawn(&args, from, to, audio)); diff --git a/phichain-renderer/src/telemetry.rs b/phichain-renderer/src/telemetry.rs new file mode 100644 index 00000000..af0b4697 --- /dev/null +++ b/phichain-renderer/src/telemetry.rs @@ -0,0 +1,122 @@ +use crate::args::{Args, Codec, MsaaLevel}; +use crate::encoder::pick_encoder; +use bevy::app::AppExit; +use bevy::prelude::Resource; +use phichain_chart::metrics::ChartMetrics; +use phichain_i18n::locale; +use phichain_telemetry::Reporter; +use serde::Serialize; +use std::sync::{Arc, Mutex}; + +const EVENT_TYPE: &str = "phichain.renderer.render"; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct VideoMeta { + pub width: u32, + pub height: u32, + pub fps: u32, + pub msaa: Option, + pub codec: Option, + pub hwaccel: bool, + pub encoder_name: &'static str, + pub quality_mode: &'static str, + pub crf: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct GameMeta { + pub note_scale: f32, + pub fc_ap_indicator: bool, + pub multi_highlight: bool, + pub hide_hit_effect: bool, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Metadata { + pub locale: String, + pub from_sec: Option, + pub to_sec: Option, + pub music_duration_sec: Option, + pub respack_used: bool, + pub video: VideoMeta, + pub game: GameMeta, + pub chart: Option, + pub success: bool, + pub error_kind: Option<&'static str>, + pub duration_ms: u64, + pub frames_written: u32, + pub avg_fps: f32, + pub realtime_factor: f32, +} + +/// Shared handle so Bevy systems and the main thread can both mutate `Metadata` while the app is running. +#[derive(Resource, Clone)] +pub struct Shared(Arc>); + +impl Shared { + pub fn new(args: &Args) -> Self { + Self(Arc::new(Mutex::new(initial(args)))) + } + + pub fn update(&self, f: F) { + if let Ok(mut guard) = self.0.lock() { + f(&mut guard); + } + } + + fn snapshot(&self) -> Metadata { + self.0.lock().expect("telemetry lock poisoned").clone() + } +} + +fn initial(args: &Args) -> Metadata { + let (quality_mode, crf) = if args.video.bitrate.is_some() { + ("bitrate", None) + } else { + ("crf", Some(args.video.crf)) + }; + + Metadata { + locale: locale(), + from_sec: args.from, + to_sec: args.to, + respack_used: args.respack.is_some(), + video: VideoMeta { + width: args.video.width, + height: args.video.height, + fps: args.video.fps, + msaa: Some(args.video.msaa), + codec: Some(args.video.codec), + hwaccel: args.video.hwaccel, + encoder_name: pick_encoder(args.video.codec, args.video.hwaccel), + quality_mode, + crf, + }, + game: GameMeta { + note_scale: args.game.note_scale, + fc_ap_indicator: args.game.fc_ap_indicator, + multi_highlight: !args.game.no_multi_highlight, + hide_hit_effect: args.game.hide_hit_effect, + }, + ..Default::default() + } +} + +pub fn report(shared: &Shared, exit: AppExit) { + if phichain_telemetry::env::telemetry_disabled() { + return; + } + + let mut meta = shared.snapshot(); + meta.success = exit.is_success(); + if !meta.success && meta.error_kind.is_none() { + meta.error_kind = Some("UnknownExit"); + } + + let reporter = Reporter::new( + "phichain-renderer", + env!("CARGO_PKG_VERSION"), + cfg!(debug_assertions), + ); + let _ = reporter.track(EVENT_TYPE, serde_json::to_value(&meta).unwrap()); +} From a65407f6edaf1aedea0c8a09d73d5a5750850e3f Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Thu, 23 Apr 2026 01:54:26 +0900 Subject: [PATCH 23/27] added adapter and hardware module for phichain-telemetry --- phichain-telemetry/Cargo.toml | 7 +++++++ phichain-telemetry/src/adapter.rs | 28 ++++++++++++++++++++++++++ phichain-telemetry/src/hardware.rs | 32 ++++++++++++++++++++++++++++++ phichain-telemetry/src/lib.rs | 6 +++++- 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 phichain-telemetry/src/adapter.rs create mode 100644 phichain-telemetry/src/hardware.rs diff --git a/phichain-telemetry/Cargo.toml b/phichain-telemetry/Cargo.toml index 0355238c..17a14e17 100644 --- a/phichain-telemetry/Cargo.toml +++ b/phichain-telemetry/Cargo.toml @@ -12,5 +12,12 @@ os_info = "3.14.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.11.0" +sysinfo = "0.35.1" ureq = "3.3.0" uuid = { version = "1.23.0", features = ["v4"] } +wgpu-types = { version = "27", optional = true } + +[features] +default = [] +# Enables `From<&wgpu_types::AdapterInfo>` for `adapter::Adapter`. +wgpu = ["dep:wgpu-types"] diff --git a/phichain-telemetry/src/adapter.rs b/phichain-telemetry/src/adapter.rs new file mode 100644 index 00000000..196f149c --- /dev/null +++ b/phichain-telemetry/src/adapter.rs @@ -0,0 +1,28 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Adapter { + /// GPU name, e.g. `"Apple M1 Pro"` or `"NVIDIA GeForce RTX 4090"` + pub name: String, + /// Device class: one of `"discrete_gpu"`, `"integrated_gpu"`, `"virtual_gpu"`, `"cpu"` (software), `"other"` + pub device_type: &'static str, + /// Graphics API: one of `"vulkan"`, `"metal"`, `"dx12"`, `"gl"`, `"browser_webgpu"`, `"noop"` + pub backend: &'static str, +} + +#[cfg(feature = "wgpu")] +impl From<&wgpu_types::AdapterInfo> for Adapter { + fn from(info: &wgpu_types::AdapterInfo) -> Self { + Self { + name: info.name.clone(), + device_type: match info.device_type { + wgpu_types::DeviceType::Other => "other", + wgpu_types::DeviceType::IntegratedGpu => "integrated_gpu", + wgpu_types::DeviceType::DiscreteGpu => "discrete_gpu", + wgpu_types::DeviceType::VirtualGpu => "virtual_gpu", + wgpu_types::DeviceType::Cpu => "cpu", + }, + backend: info.backend.to_str(), + } + } +} diff --git a/phichain-telemetry/src/hardware.rs b/phichain-telemetry/src/hardware.rs new file mode 100644 index 00000000..904ef953 --- /dev/null +++ b/phichain-telemetry/src/hardware.rs @@ -0,0 +1,32 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct Hardware { + /// CPU brand string, e.g. `"Apple M1 Pro"` or `"Intel(R) Core(TM) i7-..."` + pub cpu: String, + /// Logical core count + pub core_count: usize, + /// Total installed RAM in bytes + pub memory: u64, +} + +impl Hardware { + /// Collect hardware info from the current host. + /// + /// Internally builds a fresh `sysinfo::System` with a full refresh, which is not cheap (tens of ms on a cold cache). + /// Call once per session and cache the result. + pub fn collect() -> Self { + let mut system = sysinfo::System::new_all(); + system.refresh_all(); + let cpu = system + .cpus() + .first() + .map(|c| c.brand().to_string()) + .unwrap_or_default(); + Self { + cpu, + core_count: system.cpus().len(), + memory: system.total_memory(), + } + } +} diff --git a/phichain-telemetry/src/lib.rs b/phichain-telemetry/src/lib.rs index 0c155a53..fa8169c4 100644 --- a/phichain-telemetry/src/lib.rs +++ b/phichain-telemetry/src/lib.rs @@ -5,16 +5,20 @@ //! //! # Modules //! +//! - [`adapter`] - GPU adapter fingerprint (opt-in via `wgpu` feature for the `From<&wgpu_types::AdapterInfo>` impl) //! - [`device`] - Stable, anonymous device identification //! - [`env`] - Environment detection (opt-out, debug mode, CI, containers) +//! - [`hardware`] - CPU / memory fingerprint //! - [`payload`] - Common payload construction +pub mod adapter; pub mod device; pub mod env; +pub mod hardware; pub mod payload; pub mod report; -pub use report::{Reporter, flush, handle_subcommand}; +pub use report::{Reporter, flush, handle_subcommand, send}; /// The telemetry reporting endpoint. pub const TELEMETRY_URL: &str = "https://telemetry.phichain.rs/report"; From 44b7463ce83d7398820fbc9c7983bf9fa20463f6 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Thu, 23 Apr 2026 21:42:20 +0900 Subject: [PATCH 24/27] editor use adapter and hardware from phichain-telemetry --- phichain-editor/Cargo.toml | 2 +- phichain-editor/src/telemetry/mod.rs | 40 ++++++++++++++++------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/phichain-editor/Cargo.toml b/phichain-editor/Cargo.toml index 54fc43da..141b2a6b 100644 --- a/phichain-editor/Cargo.toml +++ b/phichain-editor/Cargo.toml @@ -72,7 +72,7 @@ smallvec = "1.13.2" uuid = { version = "1.13.1", features = ["v4"] } sysinfo = "0.35.1" -phichain-telemetry = { path = "../phichain-telemetry" } +phichain-telemetry = { path = "../phichain-telemetry", features = ["wgpu"] } flate2 = "1.1.1" kakasi = "0.1.0" pinyin = "0.10.0" diff --git a/phichain-editor/src/telemetry/mod.rs b/phichain-editor/src/telemetry/mod.rs index 3d4cf076..749f6f88 100644 --- a/phichain-editor/src/telemetry/mod.rs +++ b/phichain-editor/src/telemetry/mod.rs @@ -13,6 +13,8 @@ use phichain_chart::event::LineEvent; use phichain_chart::line::Line; use phichain_chart::note::Note; use phichain_chart::project::Project; +use phichain_telemetry::adapter::Adapter; +use phichain_telemetry::hardware::Hardware; use phichain_telemetry::payload::{PhichainMeta, TelemetryPayload}; use serde_json::{json, Value}; use std::process; @@ -102,11 +104,17 @@ fn handle_push_telemetry_event_system( .unwrap_or_default(); let average_fps = diagnostic.and_then(|x| x.average()).unwrap_or_default(); - let mut system = sysinfo::System::new_all(); - system.refresh_all(); - + // fetch process memory + let mut system = sysinfo::System::new(); let pid = process::id(); - let process = system.process(Pid::from_u32(pid)).unwrap(); + system.refresh_processes( + sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), + true, + ); + let process_memory = system + .process(Pid::from_u32(pid)) + .map(|p| p.memory()) + .unwrap_or(0); let mut phichain_meta = PhichainMeta::new(env!("CARGO_PKG_VERSION"), cfg!(debug_assertions)); @@ -119,20 +127,18 @@ fn handle_push_telemetry_event_system( .phichain(phichain_meta) .metadata(event.metadata.clone()) .extra("session_id", telemetry_manager.uuid) - .extra("hardware", json!({ - "cpu": system.cpus().first().unwrap().brand(), - "core_count": system.cpus().len(), - "memory": system.total_memory(), - "memory_formatted": format!("{:.1} GiB", system.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0), - })) - .extra("adapter", &***adapter_info) + .extra("hardware", Hardware::collect()) + .extra("adapter", Adapter::from(&***adapter_info)) .extra("project", project_info) - .extra("performance", json!({ - "fps_samples": fps_samples, - "fps": average_fps, - "entities": entities.len(), - "memory": process.memory(), - })) + .extra( + "performance", + json!({ + "fps_samples": fps_samples, + "fps": average_fps, + "entities": entities.len(), + "memory": process_memory, + }), + ) .extra("uptime", time.elapsed().as_secs_f32()) .build(); From 7803b46f65667d63b1342af665bee05e51ce2af8 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Fri, 24 Apr 2026 21:25:38 +0900 Subject: [PATCH 25/27] extracted report::send --- phichain-telemetry/src/report.rs | 59 ++++++++++++++++---------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/phichain-telemetry/src/report.rs b/phichain-telemetry/src/report.rs index ba2799ea..5b1ed147 100644 --- a/phichain-telemetry/src/report.rs +++ b/phichain-telemetry/src/report.rs @@ -59,10 +59,7 @@ impl Reporter { } } - /// Serialize a payload and hand it off to a flush subprocess. - /// - /// With `PHICHAIN_TELEMETRY_DEBUG` set, prints to stderr and returns - /// instead of spawning anything. + /// Shortcut for the common case: build a minimal payload from `event_type` + `metadata` and send it pub fn track(&self, event_type: &str, metadata: Value) -> std::io::Result<()> { let payload = TelemetryPayload::builder() .reporter(self.name) @@ -72,33 +69,37 @@ impl Reporter { .metadata(metadata) .build(); - if crate::env::telemetry_debug() { - eprintln!("[telemetry] {}", serde_json::to_string_pretty(&payload)?); - return Ok(()); - } + send(&payload) + } +} - let pid = std::process::id(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let path = - std::env::temp_dir().join(format!("{TELEMETRY_FILE_PREFIX}{pid}-{timestamp}.json")); - let file = File::create(&path)?; - serde_json::to_writer(file, &payload)?; - - let current_exe = std::env::current_exe()?; - Command::new(current_exe) - .arg("telemetry") - .arg("flush") - .arg(path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn()?; - - Ok(()) +/// Send a pre-built payload through the flush subprocess. +pub fn send(payload: &TelemetryPayload) -> std::io::Result<()> { + if crate::env::telemetry_debug() { + eprintln!("[telemetry] {}", serde_json::to_string_pretty(payload)?); + return Ok(()); } + + let pid = std::process::id(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let path = std::env::temp_dir().join(format!("{TELEMETRY_FILE_PREFIX}{pid}-{timestamp}.json")); + let file = File::create(&path)?; + serde_json::to_writer(file, payload)?; + + let current_exe = std::env::current_exe()?; + Command::new(current_exe) + .arg("telemetry") + .arg("flush") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(()) } /// Entry point for the ` telemetry flush ` subcommand. From bdbdcdea52eb3dde1fd02f13006c0847353bf82e Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 11 May 2026 19:47:49 +0800 Subject: [PATCH 26/27] renderer record hardware and adapter --- Cargo.lock | 2 + phichain-renderer/Cargo.toml | 2 +- phichain-renderer/src/main.rs | 8 ++++ phichain-renderer/src/telemetry.rs | 77 +++++++++++++++++++++++------- 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53d5a9e7..3508081a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6198,8 +6198,10 @@ dependencies = [ "serde", "serde_json", "sha2", + "sysinfo 0.35.1", "ureq 3.3.0", "uuid", + "wgpu-types", ] [[package]] diff --git a/phichain-renderer/Cargo.toml b/phichain-renderer/Cargo.toml index 6971548b..75344b93 100644 --- a/phichain-renderer/Cargo.toml +++ b/phichain-renderer/Cargo.toml @@ -22,5 +22,5 @@ tempfile = "3.10" indicatif = "0.17" rust-i18n = "=3.0.1" phichain-i18n = { path = "../phichain-i18n" } -phichain-telemetry = { path = "../phichain-telemetry" } +phichain-telemetry = { path = "../phichain-telemetry", features = ["wgpu"] } serde = { version = "1.0.228", features = ["derive"] } diff --git a/phichain-renderer/src/main.rs b/phichain-renderer/src/main.rs index bcf843b0..755e0410 100644 --- a/phichain-renderer/src/main.rs +++ b/phichain-renderer/src/main.rs @@ -26,6 +26,7 @@ use bevy::log::LogPlugin; use bevy::prelude::*; use bevy::render::gpu_readback::Readback; use bevy::render::render_resource::{TextureFormat, TextureUsages}; +use bevy::render::renderer::RenderAdapterInfo; use bevy::window::ExitCondition; use bevy::winit::WinitPlugin; use bevy_kira_audio::AudioPlugin; @@ -111,6 +112,9 @@ fn setup( mut game_config: ResMut, args: Res, telemetry: Res, + // RenderPlugin inserts this during `Plugin::build`, before Startup runs; + // guard with `Option` in case that ever changes. + adapter_info: Option>, ) { let project = Project::open(args.path.clone().into()).expect("failed to open project"); let music_duration = utils::audio_duration( @@ -126,6 +130,10 @@ fn setup( m.music_duration_sec = Some(music_duration); m.chart = Some(ChartMetrics::collect(&chart.lines)); }); + telemetry.set_hardware(phichain_telemetry::hardware::Hardware::collect()); + if let Some(info) = adapter_info { + telemetry.set_adapter(phichain_telemetry::adapter::Adapter::from(&***info)); + } // Offscreen GPU texture the camera renders into; Readback copies it out each frame. let mut target = Image::new_target_texture( diff --git a/phichain-renderer/src/telemetry.rs b/phichain-renderer/src/telemetry.rs index af0b4697..24037033 100644 --- a/phichain-renderer/src/telemetry.rs +++ b/phichain-renderer/src/telemetry.rs @@ -4,7 +4,9 @@ use bevy::app::AppExit; use bevy::prelude::Resource; use phichain_chart::metrics::ChartMetrics; use phichain_i18n::locale; -use phichain_telemetry::Reporter; +use phichain_telemetry::adapter::Adapter; +use phichain_telemetry::hardware::Hardware; +use phichain_telemetry::payload::{PhichainMeta, TelemetryPayload}; use serde::Serialize; use std::sync::{Arc, Mutex}; @@ -49,22 +51,48 @@ pub struct Metadata { pub realtime_factor: f32, } -/// Shared handle so Bevy systems and the main thread can both mutate `Metadata` while the app is running. +/// Everything the payload needs. +/// +/// Filled incrementally from both Bevy systems and the main thread. +/// `hardware` / `adapter` land at the payload top level via `.extra(...)`; +/// `metadata` becomes the nested `metadata` field. +#[derive(Default, Clone)] +struct Inner { + metadata: Metadata, + hardware: Option, + adapter: Option, +} + #[derive(Resource, Clone)] -pub struct Shared(Arc>); +pub struct Shared(Arc>); impl Shared { pub fn new(args: &Args) -> Self { - Self(Arc::new(Mutex::new(initial(args)))) + Self(Arc::new(Mutex::new(Inner { + metadata: initial(args), + ..Default::default() + }))) } pub fn update(&self, f: F) { if let Ok(mut guard) = self.0.lock() { - f(&mut guard); + f(&mut guard.metadata); + } + } + + pub fn set_hardware(&self, value: Hardware) { + if let Ok(mut guard) = self.0.lock() { + guard.hardware = Some(value); } } - fn snapshot(&self) -> Metadata { + pub fn set_adapter(&self, value: Adapter) { + if let Ok(mut guard) = self.0.lock() { + guard.adapter = Some(value); + } + } + + fn snapshot(&self) -> Inner { self.0.lock().expect("telemetry lock poisoned").clone() } } @@ -107,16 +135,33 @@ pub fn report(shared: &Shared, exit: AppExit) { return; } - let mut meta = shared.snapshot(); - meta.success = exit.is_success(); - if !meta.success && meta.error_kind.is_none() { - meta.error_kind = Some("UnknownExit"); + let Inner { + mut metadata, + hardware, + adapter, + } = shared.snapshot(); + + metadata.success = exit.is_success(); + if !metadata.success && metadata.error_kind.is_none() { + metadata.error_kind = Some("UnknownExit"); + } + + let mut builder = TelemetryPayload::builder() + .reporter("phichain-renderer") + .event_type(EVENT_TYPE) + .maybe_device_id(phichain_telemetry::device::get_device_id()) + .phichain(PhichainMeta::new( + env!("CARGO_PKG_VERSION"), + cfg!(debug_assertions), + )) + .metadata(serde_json::to_value(&metadata).unwrap()); + + if let Some(hw) = hardware { + builder = builder.extra("hardware", hw); + } + if let Some(a) = adapter { + builder = builder.extra("adapter", a); } - let reporter = Reporter::new( - "phichain-renderer", - env!("CARGO_PKG_VERSION"), - cfg!(debug_assertions), - ); - let _ = reporter.track(EVENT_TYPE, serde_json::to_value(&meta).unwrap()); + let _ = phichain_telemetry::send(&builder.build()); } From d3b14fbd96f75fcbf67df27a1977d3e9bb1e1d97 Mon Sep 17 00:00:00 2001 From: Ivan1F Date: Mon, 11 May 2026 20:39:56 +0800 Subject: [PATCH 27/27] better comment --- phichain-renderer/src/telemetry.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/phichain-renderer/src/telemetry.rs b/phichain-renderer/src/telemetry.rs index 24037033..988a83ec 100644 --- a/phichain-renderer/src/telemetry.rs +++ b/phichain-renderer/src/telemetry.rs @@ -53,9 +53,8 @@ pub struct Metadata { /// Everything the payload needs. /// -/// Filled incrementally from both Bevy systems and the main thread. -/// `hardware` / `adapter` land at the payload top level via `.extra(...)`; -/// `metadata` becomes the nested `metadata` field. +/// This struct is filled incrementally during the execution, and got finalized when rendering finishes +/// It will then be used as the telemetry payload #[derive(Default, Clone)] struct Inner { metadata: Metadata,