Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ See [capture architecture and testing](docs/capture-architecture.md).
- Full recordings and death replays use independent encoding/editing sessions sharing one pixel/FMOD source. Death
replays retain the latest 30 seconds by default, configurable from 10 to 60
seconds, save after death, and resume automatically after respawn.
- Death replays begin on the first frame of a fade-in (or according to the configured replay
length) and end on the first frame of the next segment's fade-in (respawn or level entry);
consecutive replays join on that frame.
- Continuous H.264 death replays reuse the live encoder's packets rather than encoding video twice;
adjacent room/music metadata splits keep this fast path. MP4 edit lists hide decoder preroll at
non-keyframe cuts while retaining SFX, reconstructed BGM and room-specific music policy.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ Windows 下,关注的玩家换房间且 Celeste 不在前台时,会发送系
可选的键盘或手柄按键,默认均不绑定。
- 完整录像和死亡回放使用独立的编码/剪辑会话,共享唯一的像素和 FMOD 采集源;死亡回放默认保留最近 30 秒,
可设置为 10–60 秒,并在死亡后自动保存、复活后继续录制。
- 死亡回放从渐入的第一帧(或根据预设回放时长)开始,到下一段(复活/进入关卡)渐入的第一帧结束;
相邻回放在同一帧衔接。
- 连续的 H.264 死亡回放会直接复用采集时已编码的视频,不再二次编码;普通房间/BGM 元数据切分不影响快速保存。
非关键帧开头通过 MP4 edit list 隐藏解码预滚,保留音效、BGM 重构和敏感房间策略。
暂停造成的剪切、开启“剪辑冻结帧”、不兼容的码流会自动走原精确剪辑流程。
Expand Down
114 changes: 58 additions & 56 deletions Source/AutoRecorder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ public static class AutoRecorder {
private static bool deathReplayBranchActive;
private static bool deathReplayWaitingForStablePlayer;
private static bool deathReplayPauseSuspended;
private static bool deathReplayFinalizeRequested;
private static ulong deathReplayQueuedEpoch;
private static double deathReplayClipStartSeconds;
private static string deathReplayDeathRoom = "";
private static bool fullRecordingEnabled;
private static bool reconstructBgm;
private static readonly RecordingSessionState SessionState = new();
Expand Down Expand Up @@ -171,7 +173,7 @@ public static void Update(Level level) {
}

ObserveRoom(level);
bool carryingGolden = !player.Dead && CarriesGolden(player);
bool carryingGolden = PlayerLifecycle.IsAlive(player) && CarriesGolden(player);
SessionState.Observe(settings.AutomaticRecording, carryingGolden);
// Mode changes are explicit user actions; save an existing auto session rather
// than discarding it or silently changing its ownership.
Expand All @@ -188,10 +190,11 @@ public static void AfterEngineUpdate() {
current?.CheckpointRecovery();
deathReplayCurrent?.CheckpointRecovery();
FlushPendingRecordingStop();
if (deathReplayFinalizeRequested && DeathAnimationFinished()) {
deathReplayFinalizeRequested = false;
// Keep capturing through the death animation and its final black wipe.
// The lifecycle ends a death replay on the first frame of the next segment
// (respawn wipe / level entry), then the next replay starts on that same frame.
if (PlayerLifecycle.AdvanceOnce(Engine.Scene as Level).LeftDying) {
FinalizeDeathReplayCapture();
if (MicroblocksQolUtilsModule.Settings.DeathReplayEnabled) StartDeathReplayRecording();
}
if (manualSlSuspended && MicroblocksQolUtilsModule.Settings.DeathReplayEnabled && deathReplayCurrent is null) {
StartDeathReplayRecording();
Expand Down Expand Up @@ -219,13 +222,13 @@ private static void UpdateFullRecording(Level level, Player player, QolSettings
SuspendForPause();
return;
}
if (current is null && PlayerIsRecordable(level, player)) StartRunRecording(level);
if (current is null && PlayerLifecycle.IsControllable(level, player)) StartRunRecording(level);
NativeRoomRecording? recording = current;
if (recording is null) return;

if (pauseSuspended && PlayerIsRecordable(level, player) && PauseOverlayCleared(level)) {
if (pauseSuspended && PlayerLifecycle.IsControllable(level, player) && PauseOverlayCleared(level)) {
ResumeFullRecordingAfterPause(recording);
} else if (waitingForStablePlayer && PlayerIsRecordable(level, player)) {
} else if (waitingForStablePlayer && PlayerLifecycle.IsControllable(level, player)) {
StartBranchAtCurrentTime(seamlessFromPrevious: resumeFromSavedState);
}

Expand All @@ -249,24 +252,26 @@ private static void UpdateFullRecording(Level level, Player player, QolSettings

private static void UpdateDeathReplayRecording(Level level, Player player, QolSettings settings) {
if (!settings.DeathReplayEnabled) {
if (deathReplayCurrent is not null || deathReplayFinalizeRequested)
if (deathReplayCurrent is not null)
DiscardDeathReplayRecording();
return;
}
if (manualSlSuspended) return;
if (deathReplayFinalizeRequested) return;
if (PlayerLifecycle.InDeathSequence) return;
if (level.Paused && !MicroblocksQolUtilsModule.Settings.RecordingKeepPausedFrames) {
SuspendDeathReplayForPause();
return;
}
if (deathReplayCurrent is null && PlayerIsRecordable(level, player))
// Begin on the black frame where the next scene starts fading in.
if (deathReplayCurrent is null
&& (PlayerLifecycle.IsControllable(level, player) || level.Wipe is ScreenWipe { WipeIn: true }))
StartDeathReplayRecording();
NativeRoomRecording? recording = deathReplayCurrent;
if (recording is null) return;

if (deathReplayPauseSuspended && PlayerIsRecordable(level, player) && PauseOverlayCleared(level)) {
if (deathReplayPauseSuspended && PlayerLifecycle.IsControllable(level, player) && PauseOverlayCleared(level)) {
ResumeDeathReplayAfterPause(recording);
} else if (deathReplayWaitingForStablePlayer && PlayerIsRecordable(level, player)) {
} else if (deathReplayWaitingForStablePlayer && PlayerLifecycle.IsControllable(level, player)) {
StartDeathReplayBranchAtCurrentTime();
}

Expand Down Expand Up @@ -410,16 +415,17 @@ bool registerDeathInStats
PlayerDeadBody? body = orig(self, direction, evenIfInvincible, registerDeathInStats);
if (body is null) return body;
RecordingTransitionAutoSave.Cancel();

if (deathReplayCurrent is not null) {
QueueDeathReplay(self, deathReplayCurrent);
deathReplayBranchActive = false;
deathReplayBranchSeamlessFromPrevious = false;
deathReplayWaitingForStablePlayer = true;
// Every real death is registered; the lifecycle owns the phase from here.
PlayerLifecycle.NotifyDeath(self.Scene as Level);

if (deathReplayCurrent is { } deathReplay) {
// ESC -> retry kills the player while paused, so reopen the branch here,
// or the death cutscene is never captured.
if (!deathReplayBranchActive) StartDeathReplayBranchAtCurrentTime(seamlessFromPrevious: false);
double bufferSeconds = Math.Clamp(MicroblocksQolUtilsModule.Settings.DeathReplayBufferSeconds, 10, 60);
deathReplayClipStartSeconds = Math.Max(0, deathReplay.TimelineTimeSeconds - bufferSeconds);
deathReplayDeathRoom = (self.Scene as Level)?.Session.Level ?? recordingRoomName ?? "room";
deathReplayPauseSuspended = false;
// Player.Die runs from inside EntityList.Update; the capture is stopped later by
// AfterEngineUpdate rather than doing synchronous FMOD teardown in this hook.
deathReplayFinalizeRequested = true;
}

if (current is null || pendingRecordingStop is not null) return body;
Expand Down Expand Up @@ -510,6 +516,7 @@ ref bool shouldDissociateEntities

private static void BeginRun(Level level) {
SessionState.ResetRun(keepManualRequest: true);
PlayerLifecycle.ResetRun();
runKey = RunKey(level);
areaSid = level.Session.Area.SID;
observedRespawnPoint = level.Session.RespawnPoint;
Expand Down Expand Up @@ -620,13 +627,21 @@ internal static void SuspendForManualSl(bool loading = false) {
DeathReplayPrefix.Add(deathClip);
branchActive = deathReplayBranchActive = false;
manualSlSuspended = true;
// Loading rewinds the death, so end the replay on this frame and start the next one here.
if (loading && PlayerLifecycle.Rewind().LeftDying) {
FinalizeDeathReplayCapture();
if (MicroblocksQolUtilsModule.Settings.DeathReplayEnabled) {
StartDeathReplayRecording();
deathReplayBranchActive = false;
}
}
}

internal static void ManualSlPresented(ulong timestamp) {
if (!CapturePresentationGate.AcceptGameplay || !manualSlSuspended || SpeedrunToolAutoSave.ManualOperationActive
|| Engine.Scene is not Level level || level.Paused
|| level.Wipe is not null || !PauseOverlayCleared(level)
|| level.Tracker.GetEntity<Player>() is not { } player || !PlayerIsRecordable(level, player)) return;
|| level.Tracker.GetEntity<Player>() is not { } player || !PlayerLifecycle.IsControllable(level, player)) return;
// Observe SRT, never unfreeze it. Resume video at its first clean presented
// frame, without waiting for a QolHud update or pausing the game ourselves.
ObserveRoom(level);
Expand Down Expand Up @@ -663,11 +678,6 @@ internal static void StageInternalRecoveryResume() {
saveSuspendedFull = current;
if (MicroblocksQolUtilsModule.Settings.DeathReplayEnabled && deathReplayCurrent is null)
StartDeathReplayRecording();
if (deathReplayCurrent is { } death) {
saveSuspendedDeath = death;
deathReplayBranchActive = false;
deathReplayWaitingForStablePlayer = false;
}
}

internal static bool TryResumeAfterInternalSave() {
Expand Down Expand Up @@ -812,25 +822,29 @@ private static void FinalizeCurrent(Level level) {
ResetFullRecordingState();
}

private static void QueueDeathReplay(Player player, NativeRoomRecording recording) {
QolSettings settings = MicroblocksQolUtilsModule.Settings;
if (!settings.DeathReplayEnabled) return;
private static void QueueDeathReplay(NativeRoomRecording recording) {
if (PlayerLifecycle.DeathEpoch == deathReplayQueuedEpoch
|| !MicroblocksQolUtilsModule.Settings.DeathReplayEnabled) return;

double bufferSeconds = Math.Clamp(settings.DeathReplayBufferSeconds, 10, 60);
List<RecordingClip> clips = CaptureRecentDeathReplayClips(recording, bufferSeconds);
QolSettings settings = MicroblocksQolUtilsModule.Settings;
double end = recording.TimelineTimeSeconds;
double start = Math.Min(deathReplayClipStartSeconds, end);
double windowSeconds = end - start;
if (windowSeconds <= 0) return;
List<RecordingClip> clips = CaptureRecentDeathReplayClips(recording, windowSeconds);
bool unedited = !settings.RecordingEditingEnabled;
if (unedited) {
double end = recording.TimelineTimeSeconds;
clips = [new(recording.Path, Math.Max(0, end - bufferSeconds), Math.Min(end, bufferSeconds), "", 0, BgmFollowsVideo: true)];
clips = [new(recording.Path, start, windowSeconds, "", 0, BgmFollowsVideo: true)];
}
if (clips.Count == 0) return;
// Mark the death consumed only once it actually produced an export.
deathReplayQueuedEpoch = PlayerLifecycle.DeathEpoch;

Level? level = player.Scene as Level;
PendingDeathReplays.Add(new PendingDeathReplay(
clips,
DateTime.Now,
level?.Session.Area.SID ?? areaSid,
level?.Session.Level ?? "room",
areaSid,
deathReplayDeathRoom.Length > 0 ? deathReplayDeathRoom : "room",
!unedited && reconstructBgm,
!unedited && settings.RecordingRemoveFreezeFrames
));
Expand Down Expand Up @@ -913,6 +927,7 @@ double seconds

private static void FinalizeDeathReplayCapture() {
NativeRoomRecording? recording = deathReplayCurrent;
if (recording is not null) QueueDeathReplay(recording);
deathReplayCurrent = null;
if (recording is not null) {
FinishStoppedRecording(recording, recording.StopAsync(), TakeDeathReplayJobs());
Expand All @@ -931,22 +946,6 @@ private static bool HasUncollectedGolden(Level level) {
&& !collectible.IsCollected(level.Session));
}

private static bool DeathAnimationFinished() {
if (Engine.Scene is not Level level) return true;
// A death replay must include the complete death cutscene and the wipe to black.
// Once the wipe/cutscene is gone, the next update can safely finalize it.
return !level.InCutscene && !level.SkippingCutscene && !level.Transitioning && level.Wipe is null;
}

private static bool PlayerIsRecordable(Level level, Player player) {
// A freshly respawned Player is already non-dead while the respawn wipe/animation is
// still running. Starting a retained branch there puts the tail of the death sequence
// back into manually and automatically saved videos.
return !player.Dead
&& !level.Transitioning
&& player.StateMachine.State != Player.StIntroRespawn;
}

private static bool PauseOverlayCleared(Level level) {
return !level.Paused
&& QolSettingsOverlay.ActivePage is null
Expand Down Expand Up @@ -1006,7 +1005,6 @@ private static void DiscardCurrentRecording() {
}

private static void DiscardDeathReplayRecording() {
deathReplayFinalizeRequested = false;
NativeRoomRecording? recording = deathReplayCurrent;
deathReplayCurrent = null;
if (recording is not null) FinishStoppedRecording(recording, recording.StopAsync(), []);
Expand All @@ -1017,6 +1015,7 @@ private static void DiscardDeathReplayRecording() {
private static void StopAndReset(bool deleteSource) {
NativeRoomRecording? recording = current;
NativeRoomRecording? deathRecording = deathReplayCurrent;
if (deleteSource && deathRecording is not null) QueueDeathReplay(deathRecording);
current = null;
deathReplayCurrent = null;
if (recording is not null) {
Expand Down Expand Up @@ -1181,6 +1180,7 @@ private static void DeleteTemporaryFiles(IEnumerable<string> files) {

private static void ResetTimelineState() {
manualSlSuspended = false;
PlayerLifecycle.ResetRun();
ResetFullRecordingState();
runKey = "";
areaSid = "";
Expand Down Expand Up @@ -1216,7 +1216,9 @@ private static void ResetDeathReplayState(bool waitForStablePlayer, bool keepRec
deathReplayWaitingForStablePlayer = waitForStablePlayer;
deathReplayPauseSuspended = false;
deathReplayPauseResumeAfterMediaSeconds = null;
deathReplayFinalizeRequested = false;
deathReplayClipStartSeconds = 0;
deathReplayDeathRoom = "";
deathReplayQueuedEpoch = PlayerLifecycle.DeathEpoch;
if (!keepRecording) deathReplayCurrent = null;
}

Expand Down
6 changes: 1 addition & 5 deletions Source/InstantDeaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,7 @@ public static void AfterEngineUpdate() {
return;
}

// PlayerDeadBody is not tracked by vanilla Monocle. Ask Everest to register the
// type on demand instead of indexing Tracker.Entities through GetEntity<T>().
PlayerDeadBody? body = level.Tracker
.GetEntitiesTrackIfNeeded<PlayerDeadBody>()
.FirstOrDefault() as PlayerDeadBody;
PlayerDeadBody? body = PlayerLifecycle.Latest.DeadBody;
if (body is null) {
reloadedBody = null;
return;
Expand Down
2 changes: 1 addition & 1 deletion Source/MiniMapRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ private static bool TryResolvePlayerCenter(Level level, Player? player, out Vect
return true;
}

PlayerDeadBody? deadBody = level.Entities.FindFirst<PlayerDeadBody>();
PlayerDeadBody? deadBody = PlayerLifecycle.Latest.DeadBody;
if (deadBody is null) {
center = default;
return false;
Expand Down
Loading
Loading