feat: add replay support#797
Open
lyh2011 wants to merge 16 commits into
Open
Conversation
Introduces a self-contained replay system on top of the upstream v0.7.1
phira / prpr.
prpr (engine) changes:
- New `prpr::replay` module with `ReplayData`, `NoteRecord`, JSON
serialization, and a save_replay_file() helper that writes to
{data_dir}/replays/.
- New `prpr::export` module: an Exporter that pipes raw RGBA frames into
an external `ffmpeg` CLI to produce an MP4. Audio mux is optional.
- prpr::lib: register a global data dir (set_data_dir) and re-export rfd
for use by the host.
- Judge gains `start_recording` / `take_replay_record` /
`set_replay_data` / `is_replaying` plus a `replay_update` path that
drives note state from a recorded replay.
- Hold-press events are also written to the replay buffer so playback
reproduces hold notes correctly.
- GameScene picks up a pending replay/record/exporter from thread-local
slots in `prpr::replay` / `prpr::export`. On State::Ending it persists
the recorded replay and finalizes any in-progress export.
- A new Config field `auto_record` (default true) gates recording.
phira (host) changes:
- New `dir::replays()` returning data/replays.
- New `phira::page::ReplayListPage`: lists replays grouped by chart,
supports playing back a replay or exporting it to MP4 (rfd save_file
with osascript fallback for macOS Sequoia non-bundled apps).
- Home page gets a Replay (回放) icon between message and settings.
- Settings -> Chart gets an "Auto-record replays" toggle that drives the
new Config::auto_record field. l10n added for zh-CN and en-US.
- SongScene::launch sets `prpr::replay::set_pending_record(true)` when
the user has auto_record on, so the next-built GameScene records a
replay automatically.
prpr-avc:
- ffi.rs: link kind is `static` everywhere except x86_64-apple-darwin,
which links the Homebrew ffmpeg@4 dylibs. The pre-ffmpeg-5 swresample
API is required (`swr_alloc_set_opts`).
- build.rs: on x86_64-apple-darwin, automatically point rustc at
/usr/local/opt/ffmpeg@4/lib (or HOMEBREW_PREFIX equivalent) so local
dev builds work without a custom prebuilt static slot.
Cross-platform release builds are unchanged: every target other than
x86_64-apple-darwin keeps the original prebuilt-static contract.
- Run nightly-2026-01-01 rustfmt across all replay/export/etc. files so the upstream `cargo fmt -- --check` CI is clean. - Fix clippy lints (-D warnings) introduced by the replay branch: drop unused `icons` field and `chart_id` on ReplayEntry; remove unused `rank_icon` helper; replace `sort_by(|a,b| b.cmp(&a))` with `sort_by_key(... Reverse(...))`; turn the trailing `return out;` into a tail expression in pick_save_path. - Add `.github/workflows/build-android.yml`: a manual + push-triggered Android build that installs the cmdline-tools, accepts SDK licenses, pulls NDK r27c, downloads a per-target prpr-avc-ffmpeg static-lib release tarball matching the env var, runs `cargo ndk -t arm64-v8a`, and uploads `libphira.so` as an artifact.
The previous tag 20260309_v0 was either removed from the GitHub release page or never published as an asset; the download step in build-android.yml was hitting a 404. 20260408_v0 is the newest release at the time of this commit and ships all the targets including aarch64-linux-android.
The repo's rust-toolchain.toml pins nightly-2026-01-01 as the active toolchain. The previous workflow installed stable + the Android target, but cargo (when run inside the workspace) honors rust-toolchain.toml and tried to compile with nightly, which had no Android target — hence the 'can't find crate for core' error. Fix: install stable separately for cargo-ndk's own bootstrap, then explicitly add aarch64-linux-android to the project-pinned toolchain via rustup target add. cargo-ndk is installed with '+stable' so it doesn't drag the project nightly into the host build path.
- Remove the in-page "返回" button I added; it was overlapping the global "Glory" back button rendered by MainScene. Replace it with an `on_back_pressed` override so the system back button (and the Glory button, since they go through the same codepath) navigates up one level inside ReplayListPage instead of popping the page outright. - The MP4 export button silently did nothing on Android because `pick_save_path` is a no-op there and the host ffmpeg CLI isn't shipped with the apk. Show a clear toast explaining the desktop requirement instead of vanishing the click.
Goal: a single "导出 MP4" button on every replay item that pops the same
native save dialog used by chart export — desktop (rfd), Android (SAF
via the host's `showExportDialog` JNI call), iOS (NSTemporaryDirectory
+ UIDocumentPicker) — and writes the encoded mp4 into the file the
user chose.
prpr changes:
- Refactor `prpr::export` into a module with a backend trait and one
implementation per platform:
* `desktop::FfmpegCliEncoder` — same RGBA→ffmpeg-stdin pipe as before
* `android::AndroidEncoder` — pure-JNI driver of MediaCodec H.264
+ MediaMuxer mp4. RGBA→NV12 (BT.601 limited range) is done on the
CPU; no extra Java helpers required in the host APK.
* `ios::IosEncoder` — placeholder that surfaces a clear
"not yet implemented" error; the AVAssetWriter integration is
sketched in comments and will land in a follow-up.
- The encoder always writes to a *temporary* mp4 path. After
`Exporter::finish()` succeeds, prpr publishes the temp path via a
thread-local (`publish_export_result`) so the host can copy it into
the user-chosen `File` and call `resolve_export()`.
phira changes:
- ReplayListPage drops its own ad-hoc save dialog and reuses the
workspace's chart-export plumbing (`request_export` /
`take_export` / `resolve_export`). The flow is identical to the
existing "导出谱面" feature, so it works the same way on every
platform without per-OS code in the page.
- `ExportConfig` is re-exported from the page module so other pages
can share it.
bad-note animation in replays:
- `Judge::replay_update` now spawns a `BadNote` (the dark fading
duplicate that normal gameplay shows) instead of letting the note
vanish, mirroring the visual feedback of a live Bad judgement.
The Android encoder I sketched against jni 0.22.4 hit several real-API
mismatches that I can't reliably debug without an Android device on the
critical path:
- GlobalRef -> Global<T> with explicit type parameter
- get_field needs FieldSignature, not JNIStr
- get_native_interface() doesn't exist; raw env access is via get_raw()
- JByteBuffer typing for getDirectBufferAddress
Replace it with a stub that returns 'not yet implemented' on finish().
The end-to-end pipeline (file picker -> temp mp4 -> user-chosen file ->
resolve_export) is wired and tested via the desktop ffmpeg backend; the
real MediaCodec implementation can land in a follow-up branch with
device verification.
Also:
- Move 'use anyhow::Context' into mod desktop so non-desktop builds
don't see an unused import.
- Trim ios.rs's unused 'output' field.
Reverts the cross-platform MP4 export work: the JNI 0.22 surface
(Global<T>, FieldSignature, versioned native-interface unions) made
the Android encoder backend infeasible to keep on the current
toolchain, and the user prefers to drop the feature rather than
ship a desktop-only or stubbed implementation.
Removed:
* prpr/src/export/{mod,android,ios}.rs
* `pub mod export;` and `pub use rfd;` from prpr/src/lib.rs
* `exporter` field, `set_exporter`, render-time capture/blit, and
the `State::Ending` finalize hop in prpr/src/scene/game.rs
* `take_pending_exporter()` plumbing on GameScene::new
* `ExportConfig` re-export from phira/src/page.rs
Replay list (phira/src/page/replay_list.rs) is rewritten to a
2-column grid for both folder and entry views: cell width is
(r.w - pad * (cols + 1)) / cols, items are placed at
(i % cols, i / cols), and font sizes / paddings were tightened
(score 0.55, accuracy 0.32, delete button 0.10x0.05 pinned to the
bottom-right corner) so the denser layout still reads cleanly.
Also adds local helper scripts under scripts/ for the Android
and iOS-simulator local build flows.
Drop everything that is fork-specific or unrelated to the replay
feature so the diff against TeamFlos/phira:main is reviewable as a
single PR.
Removed:
* .github/workflows/build-android.yml (fork CI)
* scripts/build-{android,ios-sim}.sh (local dev helpers)
* prpr-avc/{build.rs,ffi.rs} hacks for Intel-Mac dev linking
against Homebrew ffmpeg@4 dylibs - reverted to upstream
* prpr::set_data_dir / prpr::get_data_dir global state and the
matching call in phira/src/lib.rs
* Cosmetic msg/settings button resizing in phira/src/page/home.rs
(only the new replay button is added, existing layout preserved)
* Stale "MP4 export" wording in auto-record locale strings
Refactored:
* Replay save path now uses an explicit RecordSaveFn callback,
mirroring the existing SaveFn pattern, threaded through
LoadingScene/UnlockScene/GameScene::new. The phira side wires
it to a closure that calls prpr::replay::save_replay_to_dir
with dir::replays(). prpr no longer needs to know where the
data dir lives.
* After a replay finishes, GameScene now pushes EndingScene like
a normal play (with upload_fn / record_data / best_record
suppressed) so the user sees a result screen instead of a
silent pop back to the replay list.
Fixed:
* Replay list rank icon mapping was off by one and could not
return F. Switched to canonical prpr::judge::icon_index.
Everything builds clippy-clean on host and Android arm64-v8a.
The replay button at top+0.24 was placed below the 0.23-tall row that holds Event/Respack and the side icons, so it overflowed the area. Switch the side column to three 0.11x0.07 buttons (msg / replay / settings) at top, top+0.08 and top+0.16 so all three fit inside the existing row. Also drop the data/replays/ path from the auto-record subtitle - it's internal info; the help text just points users at Home -> Replays.
Two locally imported charts can share the same display name, which made
the previous name-based matching collapse them into one folder and
sometimes launch the wrong chart on replay. Carry phira's per-chart
`local_path` (`download/<id>` for online charts, the unzipped dir
name for local imports) through the recording pipeline so each replay
can be matched back unambiguously.
* prpr::replay::ReplayData gains `chart_local_path: String` (defaults
to empty for backward compat with old replays).
* The `set_pending_record` thread-local now carries the local_path
instead of a bool. `take_pending_record` returns Option<String>.
* Judge::start_recording takes the local_path and stores it on the
ReplayData.
* The replay list groups by a stable key (`path:<local_path>`,
falling back to `id:<chart_id>` then `name:<display>`) and
launches the chart with id > local_path > name preference.
Old replay files keep working — they fall through to the
name-based group key like before.
Author
Contributor
|
@codex review |
Contributor
There was a problem hiding this comment.
Pull request overview
该 PR 为 phira 增加“回放”能力:在引擎侧记录/回放判定事件并落盘为 JSON,在宿主侧提供回放列表入口与设置开关,用于回看历史游玩过程并缓解录屏占空间的问题。
Changes:
- prpr:新增 replay 数据结构与线程本地的“待回放/待录制”交接;Judge 增加录制与回放驱动路径;GameScene 结束时保存回放并在回放模式下禁止更新本地最佳/上传。
- phira:新增回放列表页与首页入口;设置中增加“自动录制回放”开关;启动游戏时按配置自动请求录制,并将保存逻辑通过回调下沉到宿主。
- 适配调用链:LoadingScene / UnlockScene / monitor 启动路径补齐新增参数传递。
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| prpr/src/scene/loading.rs | 向 GameScene 传递 record_save_fn 回调以支持宿主保存回放 |
| prpr/src/scene/game.rs | 接入 pending 回放/录制配置;结束时落盘回放并屏蔽回放模式的成绩更新/上传 |
| prpr/src/replay.rs | 新增 ReplayData/NoteRecord、JSON 序列化与 save_replay_to_dir,以及 thread-local 交接槽 |
| prpr/src/lib.rs | 导出 replay 模块 |
| prpr/src/judge.rs | 增加录制缓冲、回放驱动 replay_update,并在 commit/hold 事件中写入记录 |
| prpr/src/config.rs | 新增 auto_record 配置项(默认开启) |
| phira/src/scene/unlock.rs | 传递 record_save_fn 以保持启动链一致 |
| phira/src/scene/song.rs | 根据配置设置 pending 录制;提供回放保存回调实现(保存到 data/replays) |
| phira/src/page/settings.rs | 增加“自动录制回放”开关 UI |
| phira/src/page/replay_list.rs | 新增回放列表页:分组、收藏、重命名、删除、启动回放 |
| phira/src/page/home.rs | 首页新增回放入口按钮 |
| phira/src/page.rs | 导出 ReplayListPage |
| phira/src/lib.rs | 新增 dir::replays() 目录入口 |
| phira/locales/zh-CN/settings.ftl | 增加 auto-record 相关文案 |
| phira/locales/en-US/settings.ftl | 增加 auto-record 相关文案 |
| phira-monitor/src/launch.rs | 更新 LoadingScene::new 调用以匹配新增参数 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+356
to
+363
| // Pick up any pending replay/record configuration queued by | ||
| // the caller before returning. | ||
| if let Some(replay) = crate::replay::take_pending_playback() { | ||
| this.judge.set_replay_data(replay); | ||
| } else if let Some(local_path) = crate::replay::take_pending_record() { | ||
| // Record only when it actually makes sense to: a normal play with | ||
| // no autoplay and 1x speed. | ||
| if normal_mode && !this.res.config.autoplay() && (this.res.config.speed - 1.0).abs() < 1e-3 { |
Comment on lines
+1001
to
+1005
| let nt = if matches!(note.kind, NoteKind::Hold { .. }) { | ||
| rec.time | ||
| } else { | ||
| note.time | ||
| }; |
Comment on lines
+163
to
+181
| /// Convenience: serialize `data` as pretty JSON into `dir`. The filename | ||
| /// encodes timestamp and chart name to avoid collisions. Hosts can use this | ||
| /// from inside their own `RecordSaveFn`, or roll their own. | ||
| pub fn save_replay_to_dir(dir: &Path, data: &ReplayData) -> anyhow::Result<PathBuf> { | ||
| std::fs::create_dir_all(dir)?; | ||
| let safe_name: String = data | ||
| .chart_name | ||
| .chars() | ||
| .map(|c| match c { | ||
| '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', | ||
| c if c.is_control() => '_', | ||
| c => c, | ||
| }) | ||
| .collect(); | ||
| let filename = format!("{}_{}.json", data.timestamp, safe_name); | ||
| let path = dir.join(filename); | ||
| let json = serde_json::to_string_pretty(data)?; | ||
| std::fs::write(&path, json)?; | ||
| Ok(path) |
| if let Some((_, name)) = &self.current_folder { | ||
| name.clone().into() | ||
| } else { | ||
| "回放列表".into() |
| }) | ||
| .or_else(|| get_data().charts.iter().find(|c| c.info.name == replay.chart_name)) | ||
| .map(|c| c.local_path.clone()) | ||
| .ok_or_else(|| anyhow::anyhow!("找不到对应的铺面: {}", replay.chart_name))?; |
Comment on lines
+423
to
+433
| let rr = Rect::new(0.76, -top + 0.04, 0.20, 0.07); | ||
| self.favorite_filter_btn.render_shadow(ui, rr, t, |ui, path| { | ||
| ui.fill_path(&path, if chosen { WHITE } else { semi_black(0.5) }); | ||
| ui.text("仅显示收藏") | ||
| .pos(rr.center().x, rr.center().y) | ||
| .anchor(0.5, 0.5) | ||
| .no_baseline() | ||
| .size(0.35) | ||
| .max_width(rr.w - 0.02) | ||
| .color(if chosen { Color::new(0.3, 0.3, 0.3, 1.) } else { WHITE }) | ||
| .draw(); |
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




给phira添加回放功能



解决了录屏占用空间大和因为没有录屏而感到遗憾的问题
可在设置内开启或关闭自动录制,可查看录制
已过clippy,fmt