Close Phase 9B Unreal-native media export

This commit is contained in:
axiomlogicnexus 2026-06-13 07:04:11 +00:00
parent 56cd8e7802
commit 1558d59e54
16 changed files with 1593 additions and 4 deletions

View file

@ -0,0 +1,220 @@
import glob
import json
import os
import traceback
import unreal
def _log(message: str) -> None:
unreal.log(f"[HyperTwistMoviePipelineReplayExecutor] {message}")
def _log_error(message: str) -> None:
unreal.log_error(f"[HyperTwistMoviePipelineReplayExecutor] {message}")
def _get_parameter(parameters, key: str, default: str = "") -> str:
value = parameters.get(key, default)
return value.strip() if isinstance(value, str) else default
def _get_int_parameter(parameters, key: str, default: int) -> int:
value = _get_parameter(parameters, key, "")
if value == "":
return default
try:
return int(value)
except ValueError:
return default
def _resolve_render_success(results, matching_files) -> bool:
success_value = getattr(results, "success", None)
if success_value is None:
return len(matching_files) > 0
try:
return bool(success_value)
except Exception:
return len(matching_files) > 0
@unreal.uclass()
class HyperTwistMoviePipelineReplayExecutor(unreal.MoviePipelinePythonHostExecutor):
active_movie_pipeline = unreal.uproperty(unreal.MoviePipeline)
output_directory = unreal.uproperty(str)
output_base_name = unreal.uproperty(str)
output_kind = unreal.uproperty(str)
report_path = unreal.uproperty(str)
replay_path = unreal.uproperty(str)
render_map_path = unreal.uproperty(str)
def _post_init(self):
self.active_movie_pipeline = None
self.output_directory = ""
self.output_base_name = ""
self.output_kind = "video"
self.report_path = ""
self.replay_path = ""
self.render_map_path = ""
@unreal.ufunction(override=True)
def execute_delayed(self, in_pipeline_queue):
try:
(_cmd_tokens, _cmd_switches, cmd_parameters) = (
unreal.SystemLibrary.parse_command_line(unreal.SystemLibrary.get_command_line())
)
level_sequence_path = _get_parameter(cmd_parameters, "LevelSequence")
if level_sequence_path == "":
raise RuntimeError("Missing -LevelSequence= argument for replay export render.")
self.output_directory = _get_parameter(
cmd_parameters, "HyperTwistOutputDirectory", "C:/HyperTwist/media-export"
)
self.output_base_name = _get_parameter(
cmd_parameters, "HyperTwistOutputBaseName", "classic-cube-replay"
)
self.output_kind = _get_parameter(
cmd_parameters, "HyperTwistOutputKind", "video"
).lower()
self.report_path = _get_parameter(cmd_parameters, "HyperTwistExportReportPath")
self.replay_path = _get_parameter(cmd_parameters, "HyperTwistReplayPath")
self.render_map_path = _get_parameter(cmd_parameters, "HyperTwistRenderMapPath")
output_width = max(
16, _get_int_parameter(cmd_parameters, "HyperTwistOutputWidth", 1280)
)
output_height = max(
16, _get_int_parameter(cmd_parameters, "HyperTwistOutputHeight", 720)
)
output_frame_rate = max(
1, _get_int_parameter(cmd_parameters, "HyperTwistOutputFrameRate", 30)
)
custom_start_frame = max(
0, _get_int_parameter(cmd_parameters, "HyperTwistCustomStartFrame", 0)
)
custom_end_frame = max(
custom_start_frame + 1,
_get_int_parameter(
cmd_parameters, "HyperTwistCustomEndFrame", custom_start_frame + 1
),
)
pipeline_queue = (
in_pipeline_queue
if in_pipeline_queue is not None
else unreal.new_object(unreal.MoviePipelineQueue, outer=self)
)
self.pipeline_queue = pipeline_queue
new_job = pipeline_queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
if self.render_map_path:
new_job.map = unreal.SoftObjectPath(self.render_map_path)
new_job.sequence = unreal.SoftObjectPath(level_sequence_path)
job_config = new_job.get_configuration()
output_setting = job_config.find_or_add_setting_by_class(
unreal.MoviePipelineOutputSetting
)
output_setting.output_directory = unreal.DirectoryPath(self.output_directory)
output_setting.file_name_format = self.output_base_name
output_setting.output_resolution = unreal.IntPoint(output_width, output_height)
output_setting.use_custom_frame_rate = True
output_setting.output_frame_rate = unreal.FrameRate(
numerator=output_frame_rate, denominator=1
)
output_setting.override_existing_output = True
output_setting.use_custom_playback_range = True
output_setting.custom_start_frame = custom_start_frame
output_setting.custom_end_frame = custom_end_frame
output_setting.zero_pad_frame_numbers = 4
output_setting.flush_disk_writes_per_shot = True
job_config.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
if self.output_kind == "still":
job_config.find_or_add_setting_by_class(
unreal.MoviePipelineImageSequenceOutput_PNG
)
else:
mp4_output = job_config.find_or_add_setting_by_class(
unreal.MoviePipelineMP4EncoderOutput
)
mp4_output.include_audio = False
mp4_output.constant_rate_factor = 18
mp4_output.encoding_rate_control = (
unreal.MoviePipelineMP4EncodeRateControlMode.QUALITY
)
job_config.initialize_transient_settings()
target_world = self.get_last_loaded_world()
if target_world is None:
raise RuntimeError(
"Movie render pipeline could not resolve the last loaded world."
)
self.active_movie_pipeline = unreal.new_object(
unreal.MoviePipeline,
outer=target_world,
)
self.active_movie_pipeline.on_movie_pipeline_work_finished_delegate.add_function_unique(
self, "on_movie_pipeline_finished"
)
_log(
"Starting replay export render "
f"(kind={self.output_kind}, map={self.render_map_path or '<current>'}, "
f"sequence={level_sequence_path}, "
f"frames={custom_start_frame}-{custom_end_frame}, output={self.output_directory})"
)
self.active_movie_pipeline.initialize(new_job)
except Exception as error:
_log_error(
f"Replay export render setup failed: {error}\n{traceback.format_exc()}"
)
self.active_movie_pipeline = None
self.on_executor_finished_impl()
@unreal.ufunction(override=True)
def on_begin_frame(self):
super(HyperTwistMoviePipelineReplayExecutor, self).on_begin_frame()
@unreal.ufunction(override=True)
def on_map_load(self, in_world):
pass
@unreal.ufunction(override=True)
def is_rendering(self):
return self.active_movie_pipeline is not None
@unreal.ufunction(ret=None, params=[unreal.MoviePipelineOutputData])
def on_movie_pipeline_finished(self, results):
extension = ".png" if self.output_kind == "still" else ".mp4"
pattern = os.path.join(self.output_directory, "**", f"{self.output_base_name}*{extension}")
matching_files = sorted(glob.glob(pattern, recursive=True))
success = _resolve_render_success(results, matching_files)
report = {
"reportVersion": "ht-classic-cube-mrq-export/v1",
"success": success,
"outputKind": self.output_kind,
"outputDirectory": self.output_directory,
"outputBaseName": self.output_base_name,
"replayPath": self.replay_path,
"files": matching_files,
}
if self.report_path:
report_directory = os.path.dirname(self.report_path)
if report_directory:
os.makedirs(report_directory, exist_ok=True)
with open(self.report_path, "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2)
_log(
f"Finished replay export render (success={success}, files={len(matching_files)})"
)
self.active_movie_pipeline = None
self.on_executor_finished_impl()

View file

@ -0,0 +1,23 @@
import unreal
import HyperTwistMoviePipelineReplayExecutor
def _register_hyper_twist_movie_pipeline_executor() -> None:
executor_cdo = unreal.get_default_object(unreal.MoviePipelinePythonHostExecutor)
executor_cdo.set_editor_property(
"executor_class",
HyperTwistMoviePipelineReplayExecutor.HyperTwistMoviePipelineReplayExecutor.static_class(),
)
unreal.log(
"[HyperTwistMoviePipelineReplayExecutor] Registered Python host executor class."
)
try:
_register_hyper_twist_movie_pipeline_executor()
except Exception as error:
unreal.log_error(
"[HyperTwistMoviePipelineReplayExecutor] Failed to register Python host executor "
f"class: {error}"
)

View file

@ -7,6 +7,7 @@
#include "EngineUtils.h"
#include "GameFramework/PlayerController.h"
#include "HAL/PlatformFileManager.h"
#include "HAL/PlatformMisc.h"
#include "HyperTwistBootstrap/HyperTwistContractLibrary.h"
#include "HyperTwistRecognition/HyperTwistSpeechLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayPersistenceLibrary.h"
@ -21,7 +22,9 @@
#include "Interfaces/VoiceCapture.h"
#include "JsonObjectConverter.h"
#include "Kismet/GameplayStatics.h"
#include "Misc/CommandLine.h"
#include "Misc/FileHelper.h"
#include "Misc/Parse.h"
#include "Misc/Paths.h"
#include "Sound/SoundWaveProcedural.h"
#include "UObject/UnrealType.h"
@ -318,6 +321,8 @@ void AHyperTwistClassicCubeGameMode::BeginPlay()
{
Super::BeginPlay();
ApplyLaunchOverridesFromCommandLine();
if (APlayerController* PlayerController =
GetWorld() != nullptr ? GetWorld()->GetFirstPlayerController() : nullptr)
{
@ -336,7 +341,27 @@ void AHyperTwistClassicCubeGameMode::BeginPlay()
LoadLocalLeaderboardState();
RefreshLocalLeaderboardLine();
ActiveSessionMode = DefaultSessionMode;
StartFreshAttempt();
if (!LaunchOverrideReplayPath.IsEmpty())
{
FString ResolvedReplayPath;
if (!LoadReplayFromFile(LaunchOverrideReplayPath, ResolvedReplayPath))
{
ResultLineOverride = TEXT("result: replay load failed");
ReplayLineOverride = TEXT("replay: command-line replay load failed");
HintLineOverride = TEXT("hint: verify the replay path and packet format");
ModeLineOverride = TEXT("mode: replay load failed");
if (bAutoQuitAfterReplayCompletion)
{
QueueReplayAutoExit(0.0);
}
RefreshHud();
}
}
else
{
StartFreshAttempt();
}
}
void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds)
@ -351,6 +376,7 @@ void AHyperTwistClassicCubeGameMode::Tick(const float DeltaSeconds)
TickVoiceCommandCapture();
TickReplayPlayback(DeltaSeconds);
TickReplayAutoExit(DeltaSeconds);
UHyperTwistTrainingSubsystem* TrainingSubsystem = ResolveTrainingSubsystem();
if (ActiveCubeActor != nullptr)
@ -697,9 +723,69 @@ void AHyperTwistClassicCubeGameMode::TickReplayPlayback(const float DeltaSeconds
);
ModeLineOverride = TEXT("mode: replay playback complete");
RefreshSolverGuidance(true);
if (bAutoQuitAfterReplayCompletion)
{
QueueReplayAutoExit(ReplayAutoQuitTailSeconds);
}
}
}
void AHyperTwistClassicCubeGameMode::ApplyLaunchOverridesFromCommandLine()
{
const TCHAR* CommandLine = FCommandLine::Get();
bLaunchOverrideDisableHud = FParse::Param(CommandLine, TEXT("HyperTwistDisableHud"));
bAutoQuitAfterReplayCompletion = FParse::Param(
CommandLine,
TEXT("HyperTwistAutoQuitAfterReplay")
);
if (bLaunchOverrideDisableHud)
{
bAutoCreateHud = false;
}
FString ReplayPathOverride;
if (FParse::Value(CommandLine, TEXT("HyperTwistReplayPath="), ReplayPathOverride))
{
ReplayPathOverride.TrimQuotesInline();
LaunchOverrideReplayPath = ReplayPathOverride;
}
double ParsedTailSeconds = ReplayAutoQuitTailSeconds;
if (FParse::Value(CommandLine, TEXT("HyperTwistReplayAutoQuitTailSeconds="), ParsedTailSeconds))
{
ReplayAutoQuitTailSeconds = FMath::Max(ParsedTailSeconds, 0.0);
}
}
void AHyperTwistClassicCubeGameMode::QueueReplayAutoExit(const double CountdownSeconds)
{
bReplayAutoQuitPending = true;
ReplayAutoQuitCountdownSeconds = FMath::Max(CountdownSeconds, 0.0);
HintLineOverride = FString::Printf(
TEXT("hint: exiting render lane in %.1fs"),
ReplayAutoQuitCountdownSeconds
);
}
void AHyperTwistClassicCubeGameMode::TickReplayAutoExit(const float DeltaSeconds)
{
if (!bReplayAutoQuitPending || ReplayAutoQuitCountdownSeconds < 0.0)
{
return;
}
ReplayAutoQuitCountdownSeconds -= FMath::Max(static_cast<double>(DeltaSeconds), 0.0);
if (ReplayAutoQuitCountdownSeconds > 0.0)
{
return;
}
bReplayAutoQuitPending = false;
ReplayAutoQuitCountdownSeconds = -1.0;
FGenericPlatformMisc::RequestExit(false);
}
FString AHyperTwistClassicCubeGameMode::ResolveActivePuzzleId() const
{
if (bHasReplayMetadata

View file

@ -1,9 +1,21 @@
#include "HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayReviewLibrary.h"
#include "JsonObjectConverter.h"
#include "Misc/Paths.h"
namespace HyperTwistTrainingMediaExportLibraryInternal
{
const TCHAR* MediaExportRepo = TEXT("remotion-dev/remotion");
const TCHAR* MediaExportLicense = TEXT("Remotion custom two-tier license; @remotion/studio is MIT but player, renderer, and media-parser remain under the checked Remotion license posture");
const TCHAR* DefaultClassicCubeMapPath = TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining");
const TCHAR* DefaultClassicCubeLevelSequencePath =
TEXT("/Game/HyperTwistTraining/Sequences/LS_HyperTwist_ClassicReplayCapture.LS_HyperTwist_ClassicReplayCapture");
const int32 DefaultOutputWidth = 1280;
const int32 DefaultOutputHeight = 720;
const int32 DefaultOutputFrameRate = 30;
const int32 DefaultTailDurationMs = 1500;
FHyperTwistTrainingSourceAttribution MakeAttribution(
const FString& TrackId,
@ -43,6 +55,81 @@ namespace HyperTwistTrainingMediaExportLibraryInternal
Reference.SourceAttribution = SourceAttribution;
return Reference;
}
template <typename TStruct>
bool DeserializePayload(const FString& Json, TStruct& OutValue)
{
return !Json.IsEmpty()
&& FJsonObjectConverter::JsonObjectStringToUStruct(Json, &OutValue, 0, 0);
}
FString FormatMilliseconds(const int32 Milliseconds)
{
const int32 SafeMilliseconds = FMath::Max(Milliseconds, 0);
const int32 Minutes = SafeMilliseconds / 60000;
const int32 Seconds = (SafeMilliseconds / 1000) % 60;
const int32 MillisRemainder = SafeMilliseconds % 1000;
return FString::Printf(TEXT("%02d:%02d.%03d"), Minutes, Seconds, MillisRemainder);
}
FString SanitizeFileStem(const FString& InValue)
{
FString Sanitized = InValue.ToLower();
for (int32 Index = 0; Index < Sanitized.Len(); ++Index)
{
TCHAR& Character = Sanitized[Index];
if (FChar::IsAlnum(Character))
{
continue;
}
Character = TEXT('-');
}
while (Sanitized.ReplaceInline(TEXT("--"), TEXT("-")) > 0)
{
}
Sanitized.TrimStartAndEndInline();
while (Sanitized.StartsWith(TEXT("-")))
{
Sanitized.RightChopInline(1, EAllowShrinking::No);
}
while (Sanitized.EndsWith(TEXT("-")))
{
Sanitized.LeftChopInline(1, EAllowShrinking::No);
}
return Sanitized.IsEmpty() ? TEXT("classic-cube-replay") : Sanitized;
}
int32 ResolveReplayDurationMs(const FHyperTwistReplayPacket& ReplayPacket)
{
const FHyperTwistDerivedReplaySummary Summary =
ReplayPacket.DerivedSummary.TotalTimeMs > 0 || ReplayPacket.DerivedSummary.MoveCount > 0
? ReplayPacket.DerivedSummary
: UHyperTwistReplayLibrary::DeriveReplaySummary(ReplayPacket);
int32 ResolvedDurationMs = FMath::Max(Summary.TotalTimeMs, 0);
for (const FHyperTwistReplayEvent& Event : ReplayPacket.Events)
{
ResolvedDurationMs = FMath::Max(ResolvedDurationMs, FMath::Max(0, Event.TimeMs));
}
return ResolvedDurationMs;
}
FString BuildReplayResult(const FHyperTwistReplayPacket& ReplayPacket)
{
if (!ReplayPacket.DerivedSummary.Result.IsEmpty())
{
return ReplayPacket.DerivedSummary.Result;
}
const FHyperTwistDerivedReplaySummary DerivedSummary =
UHyperTwistReplayLibrary::DeriveReplaySummary(ReplayPacket);
return DerivedSummary.Result;
}
}
FHyperTwistTrainingMediaExportReferenceBundle
@ -548,3 +635,149 @@ FString UHyperTwistTrainingMediaExportLibrary::BuildPackageChecklistTsv(
return Output;
}
bool UHyperTwistTrainingMediaExportLibrary::BuildClassicCubeMediaExportPlan(
const FHyperTwistReplayPacket& ReplayPacket,
const FString& ReplayPath,
const FString& OutputDirectory,
FHyperTwistTrainingClassicCubeMediaExportPlan& OutPlan
)
{
using namespace HyperTwistTrainingMediaExportLibraryInternal;
OutPlan = FHyperTwistTrainingClassicCubeMediaExportPlan();
if (!ReplayPacket.IsStructurallyValid() || OutputDirectory.IsEmpty())
{
return false;
}
const FHyperTwistReplayReviewAnalytics Analytics =
UHyperTwistReplayReviewLibrary::DeriveReplayReviewAnalytics(ReplayPacket);
const FHyperTwistDerivedReplaySummary Summary =
ReplayPacket.DerivedSummary.TotalTimeMs > 0 || ReplayPacket.DerivedSummary.MoveCount > 0
? ReplayPacket.DerivedSummary
: UHyperTwistReplayLibrary::DeriveReplaySummary(ReplayPacket);
FHyperTwistClassicCubeReplayMetadata ReplayMetadata;
const bool bHasReplayMetadata = DeserializePayload(
ReplayPacket.AnnotationsJson,
ReplayMetadata
) && ReplayMetadata.IsStructurallyValid();
const FString ReplayIdentity = !ReplayPacket.ReplayId.IsEmpty()
? ReplayPacket.ReplayId
: !ReplayPacket.SessionId.IsEmpty() ? ReplayPacket.SessionId : TEXT("classic-cube-replay");
const FString OutputBaseName = FString::Printf(
TEXT("classic-cube-%s"),
*SanitizeFileStem(ReplayIdentity)
);
const int32 ReplayDurationMs = ResolveReplayDurationMs(ReplayPacket);
const int32 TailDurationMs = DefaultTailDurationMs;
const int32 RenderEndFrame = FMath::Max(
1,
FMath::CeilToInt(
static_cast<double>(ReplayDurationMs + TailDurationMs)
* static_cast<double>(DefaultOutputFrameRate)
/ 1000.0
)
);
const int32 StillFrame = FMath::Max(0, RenderEndFrame - 1);
const int32 FinalTimeMs = Summary.TotalTimeMs > 0 ? Summary.TotalTimeMs : ReplayDurationMs;
const FString ReplayResult = BuildReplayResult(ReplayPacket);
const FString FinalTimeLabel = ReplayResult.Equals(TEXT("dnf"), ESearchCase::IgnoreCase)
? TEXT("DNF")
: FormatMilliseconds(FinalTimeMs);
const FString ScrambleNotation = bHasReplayMetadata ? ReplayMetadata.ScrambleNotation : FString();
const int32 ScrambleLength = bHasReplayMetadata ? FMath::Max(ReplayMetadata.ScrambleLength, 0) : 0;
const FString ShareSubtitle = ScrambleLength > 0
? FString::Printf(TEXT("%d-move scramble"), ScrambleLength)
: TEXT("scramble metadata unavailable");
const FString ShareFooter = ScrambleNotation.IsEmpty()
? FString::Printf(
TEXT("%d moves | %.2f TPS"),
FMath::Max(Summary.MoveCount, 0),
FMath::Max(Summary.TPS, 0.0f)
)
: FString::Printf(
TEXT("%s | %d moves | %.2f TPS"),
*ScrambleNotation,
FMath::Max(Summary.MoveCount, 0),
FMath::Max(Summary.TPS, 0.0f)
);
OutPlan.PlanVersion = TEXT("ht-classic-cube-media-export/v1");
OutPlan.ReplayId = ReplayIdentity;
OutPlan.ReplayPath = ReplayPath;
OutPlan.PuzzleId = !ReplayPacket.PuzzleDefinition.PuzzleId.IsEmpty()
? ReplayPacket.PuzzleDefinition.PuzzleId
: TEXT("cube/3x3x3");
OutPlan.MapPath = DefaultClassicCubeMapPath;
OutPlan.LevelSequencePath = DefaultClassicCubeLevelSequencePath;
OutPlan.VideoRoute = TEXT("ue-movie-render-queue");
OutPlan.StillRoute = TEXT("ue-movie-render-queue");
OutPlan.ShareImageRoute = TEXT("powershell-share-card");
OutPlan.OutputDirectory = OutputDirectory;
OutPlan.OutputBaseName = OutputBaseName;
OutPlan.VideoOutputDirectory = FPaths::Combine(OutputDirectory, TEXT("video"));
OutPlan.StillOutputDirectory = FPaths::Combine(OutputDirectory, TEXT("still"));
OutPlan.ValidationDirectory = FPaths::Combine(OutputDirectory, TEXT("validation"));
OutPlan.ExpectedVideoPath = FPaths::Combine(
OutPlan.VideoOutputDirectory,
OutputBaseName + TEXT(".mp4")
);
OutPlan.ExpectedStillFramePath = FPaths::Combine(
OutPlan.StillOutputDirectory,
OutputBaseName + TEXT("-still.png")
);
OutPlan.ExpectedShareCardPath = FPaths::Combine(OutputDirectory, OutputBaseName + TEXT("-share.png"));
OutPlan.ExpectedReportPath = FPaths::Combine(
OutPlan.ValidationDirectory,
OutputBaseName + TEXT("-report.json")
);
OutPlan.ExpectedVideoReportPath = FPaths::Combine(
OutPlan.ValidationDirectory,
OutputBaseName + TEXT("-video-report.json")
);
OutPlan.ExpectedStillReportPath = FPaths::Combine(
OutPlan.ValidationDirectory,
OutputBaseName + TEXT("-still-report.json")
);
OutPlan.OutputWidth = DefaultOutputWidth;
OutPlan.OutputHeight = DefaultOutputHeight;
OutPlan.OutputFrameRate = DefaultOutputFrameRate;
OutPlan.ReplayDurationMs = ReplayDurationMs;
OutPlan.TailDurationMs = TailDurationMs;
OutPlan.RenderStartFrame = 0;
OutPlan.RenderEndFrame = RenderEndFrame;
OutPlan.StillFrame = StillFrame;
OutPlan.MoveCount = FMath::Max(Summary.MoveCount, 0);
OutPlan.InspectionTimeMs = FMath::Max(Summary.InspectionTimeMs, 0);
OutPlan.FinalTimeMs = FinalTimeMs;
OutPlan.ReviewAnchorCount = Analytics.ReviewAnchors.Num();
OutPlan.TPS = FMath::Max(Summary.TPS, 0.0f);
OutPlan.FinalTimeLabel = FinalTimeLabel;
OutPlan.ReplayResult = ReplayResult;
OutPlan.ScrambleNotation = ScrambleNotation;
OutPlan.ScrambleLength = ScrambleLength;
OutPlan.ShareTitle = FinalTimeLabel;
OutPlan.ShareSubtitle = ShareSubtitle;
OutPlan.ShareFooter = ShareFooter;
OutPlan.bCanExportVideo = true;
OutPlan.bCanExportShareImage = true;
return OutPlan.IsStructurallyValid();
}
FString UHyperTwistTrainingMediaExportLibrary::BuildClassicCubeMediaExportPlanJson(
const FHyperTwistTrainingClassicCubeMediaExportPlan& Plan
)
{
FString Json;
FJsonObjectConverter::UStructToJsonObjectString(
FHyperTwistTrainingClassicCubeMediaExportPlan::StaticStruct(),
&Plan,
Json,
0,
0
);
return Json;
}

View file

@ -125,11 +125,14 @@ public:
bool IsVoiceCommandCaptureActive() const;
protected:
void ApplyLaunchOverridesFromCommandLine();
void QueueReplayAutoExit(double CountdownSeconds);
UHyperTwistTrainingSubsystem* ResolveTrainingSubsystem() const;
AHyperTwistClassicCubeActor* ResolveOrSpawnCubeActor();
UHyperTwistClassicCubeHUDWidget* ResolveOrCreateHudWidget();
void RefreshHud();
void TickReplayPlayback(float DeltaSeconds);
void TickReplayAutoExit(float DeltaSeconds);
FString BuildSessionId() const;
FString ResolveActivePuzzleId() const;
void WriteActiveReplayMetadata();
@ -190,6 +193,7 @@ private:
FString LeaderboardLineOverride;
FString LastReplayExportPath;
FString LocalLeaderboardResolvedPath;
FString LaunchOverrideReplayPath;
FString ResultLineOverride;
FString HintLineOverride;
FString ModeLineOverride;
@ -202,6 +206,9 @@ private:
bool bInitialReplaySnapshotCaptured = false;
bool bHasReplayMetadata = false;
bool bReplayPlaybackActive = false;
bool bLaunchOverrideDisableHud = false;
bool bAutoQuitAfterReplayCompletion = false;
bool bReplayAutoQuitPending = false;
bool bVoiceCommandCaptureActive = false;
bool bScrambleReadyNarrated = false;
bool bSolveStartNarrated = false;
@ -213,6 +220,8 @@ private:
int32 VoiceCaptureSampleRateHz = 16000;
int32 VoiceCaptureChannelCount = 1;
double ReplayPlaybackElapsedSeconds = 0.0;
double ReplayAutoQuitTailSeconds = 1.5;
double ReplayAutoQuitCountdownSeconds = -1.0;
double VoiceCaptureStartedAtSeconds = 0.0;
};

View file

@ -2,6 +2,8 @@
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "HyperTwistReplay/HyperTwistReplayTypes.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeTypes.h"
#include "HyperTwistTraining/HyperTwistTrainingTypes.h"
#include "HyperTwistTrainingMediaExportLibrary.generated.h"
@ -292,6 +294,175 @@ struct FHyperTwistTrainingMediaExportReferenceBundle
}
};
USTRUCT(BlueprintType)
struct FHyperTwistTrainingClassicCubeMediaExportPlan
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString PlanVersion = TEXT("ht-classic-cube-media-export/v1");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ReplayId;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ReplayPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString PuzzleId = TEXT("cube/3x3x3");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString MapPath = TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString LevelSequencePath =
TEXT("/Game/HyperTwistTraining/Sequences/LS_HyperTwist_ClassicReplayCapture.LS_HyperTwist_ClassicReplayCapture");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString VideoRoute = TEXT("ue-movie-render-queue");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString StillRoute = TEXT("ue-movie-render-queue");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ShareImageRoute = TEXT("powershell-share-card");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString OutputDirectory;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString OutputBaseName;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString VideoOutputDirectory;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString StillOutputDirectory;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ValidationDirectory;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ExpectedVideoPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ExpectedStillFramePath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ExpectedShareCardPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ExpectedReportPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ExpectedVideoReportPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ExpectedStillReportPath;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 OutputWidth = 1280;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 OutputHeight = 720;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 OutputFrameRate = 30;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ReplayDurationMs = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 TailDurationMs = 1500;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 RenderStartFrame = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 RenderEndFrame = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 StillFrame = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 MoveCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 InspectionTimeMs = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 FinalTimeMs = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ReviewAnchorCount = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
float TPS = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString FinalTimeLabel;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ReplayResult;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ScrambleNotation;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
int32 ScrambleLength = 0;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ShareTitle;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ShareSubtitle;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
FString ShareFooter;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bCanExportVideo = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist")
bool bCanExportShareImage = false;
bool IsStructurallyValid() const
{
return !PlanVersion.IsEmpty()
&& !ReplayId.IsEmpty()
&& !PuzzleId.IsEmpty()
&& !MapPath.IsEmpty()
&& !LevelSequencePath.IsEmpty()
&& !VideoRoute.IsEmpty()
&& !StillRoute.IsEmpty()
&& !ShareImageRoute.IsEmpty()
&& !OutputDirectory.IsEmpty()
&& !OutputBaseName.IsEmpty()
&& !VideoOutputDirectory.IsEmpty()
&& !StillOutputDirectory.IsEmpty()
&& !ValidationDirectory.IsEmpty()
&& !ExpectedVideoPath.IsEmpty()
&& !ExpectedStillFramePath.IsEmpty()
&& !ExpectedShareCardPath.IsEmpty()
&& !ExpectedReportPath.IsEmpty()
&& !ExpectedVideoReportPath.IsEmpty()
&& !ExpectedStillReportPath.IsEmpty()
&& OutputWidth > 0
&& OutputHeight > 0
&& OutputFrameRate > 0
&& ReplayDurationMs >= 0
&& TailDurationMs >= 0
&& RenderStartFrame >= 0
&& RenderEndFrame > RenderStartFrame
&& StillFrame >= RenderStartFrame
&& StillFrame < RenderEndFrame
&& MoveCount >= 0
&& InspectionTimeMs >= 0
&& FinalTimeMs >= 0
&& ReviewAnchorCount >= 0;
}
};
UCLASS()
class UNREALHYPERTWIST_API UHyperTwistTrainingMediaExportLibrary : public UBlueprintFunctionLibrary
{
@ -345,4 +516,17 @@ public:
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|MediaExport")
static FString BuildPackageChecklistTsv(const FHyperTwistTrainingMediaExportReferenceBundle& Bundle);
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|MediaExport")
static bool BuildClassicCubeMediaExportPlan(
const FHyperTwistReplayPacket& ReplayPacket,
const FString& ReplayPath,
const FString& OutputDirectory,
FHyperTwistTrainingClassicCubeMediaExportPlan& OutPlan
);
UFUNCTION(BlueprintPure, Category = "HyperTwist|Training|MediaExport")
static FString BuildClassicCubeMediaExportPlanJson(
const FHyperTwistTrainingClassicCubeMediaExportPlan& Plan
);
};

View file

@ -12,6 +12,7 @@
#include "HyperTwistSimulation/HyperTwistClassicCubeLeaderboardLibrary.h"
#include "HyperTwistSimulation/HyperTwistClassicCubeTypes.h"
#include "HyperTwistSimulation/HyperTwistPuzzleViewerComponent.h"
#include "HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.h"
#include "HyperTwistTraining/HyperTwistTrainingSubsystem.h"
#include "JsonObjectConverter.h"
#include "Misc/FileHelper.h"
@ -144,6 +145,15 @@ namespace HyperTwistClassicCubeIntegrationTestInternal
);
}
FString BuildMediaExportFixturePath()
{
return FPaths::Combine(
FPaths::ProjectSavedDir(),
TEXT("Automation"),
TEXT("classic-cube-media-export-fixture.json")
);
}
int32 CountReplayEventsOfType(
const FHyperTwistReplayPacket& Packet,
const EHyperTwistReplayEventType EventType
@ -542,6 +552,201 @@ bool FHyperTwistClassicCubeReplayPersistenceNormalizationTest::RunTest(const FSt
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeMediaExportFixtureTest,
"HyperTwist.Integration.ClassicCube.MediaExportFixture",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistClassicCubeMediaExportFixtureTest::RunTest(const FString& Parameters)
{
const FHyperTwistReplayPacket Packet =
HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
const FString FixturePath =
HyperTwistClassicCubeIntegrationTestInternal::BuildMediaExportFixturePath();
FString ExpectedFixturePath = FPaths::ConvertRelativePathToFull(FixturePath);
FPaths::NormalizeFilename(ExpectedFixturePath);
IFileManager::Get().MakeDirectory(*FPaths::GetPath(FixturePath), true);
IFileManager::Get().Delete(*FixturePath);
FString SavedPath;
TestTrue(
TEXT("The media-export fixture replay packet must save to disk."),
UHyperTwistReplayPersistenceLibrary::SaveReplayPacketToFile(
Packet,
FixturePath,
SavedPath
)
);
FPaths::NormalizeFilename(SavedPath);
TestEqual(
TEXT("The media-export fixture replay path must resolve to the expected location."),
SavedPath,
ExpectedFixturePath
);
FHyperTwistReplayPacket LoadedPacket;
FString LoadedPath;
TestTrue(
TEXT("The media-export fixture replay packet must load back from disk."),
UHyperTwistReplayPersistenceLibrary::LoadReplayPacketFromFile(
FixturePath,
LoadedPacket,
LoadedPath
)
);
FPaths::NormalizeFilename(LoadedPath);
TestEqual(
TEXT("The loaded media-export fixture replay path must resolve to the expected location."),
LoadedPath,
ExpectedFixturePath
);
TestTrue(
TEXT("The media-export fixture replay packet must remain structurally valid."),
LoadedPacket.IsStructurallyValid()
);
TestEqual(
TEXT("The media-export fixture replay packet must preserve its replay identity."),
LoadedPacket.ReplayId,
Packet.ReplayId
);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeMediaExportPlanTest,
"HyperTwist.Integration.ClassicCube.MediaExportPlan",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter
)
bool FHyperTwistClassicCubeMediaExportPlanTest::RunTest(const FString& Parameters)
{
const FHyperTwistReplayPacket Packet =
HyperTwistClassicCubeIntegrationTestInternal::BuildReplayPacket();
const FString FixturePath =
HyperTwistClassicCubeIntegrationTestInternal::BuildMediaExportFixturePath();
const FString OutputDirectory = FPaths::Combine(
FPaths::ProjectSavedDir(),
TEXT("Automation"),
TEXT("ClassicCubeMediaExport")
);
FHyperTwistTrainingClassicCubeMediaExportPlan Plan;
TestTrue(
TEXT("The media-export library must derive a classic-cube export plan from a replay packet."),
UHyperTwistTrainingMediaExportLibrary::BuildClassicCubeMediaExportPlan(
Packet,
FixturePath,
OutputDirectory,
Plan
)
);
TestTrue(
TEXT("The derived classic-cube export plan must be structurally valid."),
Plan.IsStructurallyValid()
);
TestEqual(
TEXT("The media-export plan must preserve the replay identity."),
Plan.ReplayId,
Packet.ReplayId
);
TestEqual(
TEXT("The media-export plan must preserve the replay file location."),
Plan.ReplayPath,
FixturePath
);
TestEqual(
TEXT("The media-export plan must preserve the classic-cube training map route."),
Plan.MapPath,
TEXT("/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining")
);
TestEqual(
TEXT("The media-export plan must use Movie Render Queue as the primary video route."),
Plan.VideoRoute,
TEXT("ue-movie-render-queue")
);
TestEqual(
TEXT("The media-export plan must preserve classic-cube scramble length metadata."),
Plan.ScrambleLength,
9
);
TestEqual(
TEXT("The media-export plan must preserve classic-cube move count metadata."),
Plan.MoveCount,
10
);
TestEqual(
TEXT("The media-export plan must derive the canonical final time label."),
Plan.FinalTimeLabel,
TEXT("00:01.320")
);
TestTrue(
TEXT("The media-export plan must derive an MP4 output location."),
Plan.ExpectedVideoPath.EndsWith(TEXT(".mp4"))
&& Plan.ExpectedVideoPath.Contains(TEXT("/video/"))
);
TestEqual(
TEXT("The media-export plan must derive the deterministic video directory."),
Plan.VideoOutputDirectory,
FPaths::Combine(OutputDirectory, TEXT("video"))
);
TestTrue(
TEXT("The media-export plan must derive a still-frame PNG output location."),
Plan.ExpectedStillFramePath.EndsWith(TEXT(".png"))
&& Plan.ExpectedStillFramePath.Contains(TEXT("/still/"))
);
TestEqual(
TEXT("The media-export plan must derive the deterministic still directory."),
Plan.StillOutputDirectory,
FPaths::Combine(OutputDirectory, TEXT("still"))
);
TestTrue(
TEXT("The media-export plan must derive a share-card PNG output location."),
Plan.ExpectedShareCardPath.EndsWith(TEXT(".png"))
);
TestTrue(
TEXT("The media-export plan must derive a JSON report output location."),
Plan.ExpectedReportPath.EndsWith(TEXT(".json"))
&& Plan.ExpectedReportPath.Contains(TEXT("/validation/"))
);
TestTrue(
TEXT("The media-export plan must derive the deterministic video-report location."),
Plan.ExpectedVideoReportPath.EndsWith(TEXT("-video-report.json"))
&& Plan.ExpectedVideoReportPath.Contains(TEXT("/validation/"))
);
TestTrue(
TEXT("The media-export plan must derive the deterministic still-report location."),
Plan.ExpectedStillReportPath.EndsWith(TEXT("-still-report.json"))
&& Plan.ExpectedStillReportPath.Contains(TEXT("/validation/"))
);
TestEqual(
TEXT("The media-export plan must derive the deterministic validation directory."),
Plan.ValidationDirectory,
FPaths::Combine(OutputDirectory, TEXT("validation"))
);
TestTrue(
TEXT("The media-export plan must derive a positive render frame range."),
Plan.RenderEndFrame > Plan.RenderStartFrame
);
TestTrue(
TEXT("The still frame must fall inside the render frame range."),
Plan.StillFrame >= Plan.RenderStartFrame && Plan.StillFrame < Plan.RenderEndFrame
);
const FString PlanJson =
UHyperTwistTrainingMediaExportLibrary::BuildClassicCubeMediaExportPlanJson(Plan);
TestTrue(
TEXT("The media-export plan JSON must include the share-card route."),
PlanJson.Contains(TEXT("powershell-share-card"))
);
TestTrue(
TEXT("The media-export plan JSON must include the replay fixture path."),
PlanJson.Contains(TEXT("classic-cube-media-export-fixture.json"))
);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FHyperTwistClassicCubeLeaderboardPersistenceTest,
"HyperTwist.Integration.ClassicCube.LeaderboardPersistence",

View file

@ -57,6 +57,13 @@
{
"Name": "WebBrowserWidget",
"Enabled": true
},
{
"Name": "MovieRenderPipeline",
"Enabled": true,
"TargetAllowList": [
"Editor"
]
}
]
}

View file

@ -412,9 +412,10 @@ Closure read:
- [x] Replay save, load, and viewer-import paths now repair schema-light packet version, ids, monotonic event ordering, monotonic timestamps, and derived replay summary during normalization
### 9B — Media Export
- [ ] Use UE `MovieRenderQueue` as the primary video-export route; treat `remotion-dev/remotion` only as restrictive-custody reference context unless a later clean-room/specification pass explicitly reopens it
- [ ] Export solve animation as MP4
- [ ] Export scramble + time as shareable image
- [x] Use UE `MovieRenderQueue` as the primary video-export route; treat `remotion-dev/remotion` only as restrictive-custody reference context unless a later clean-room/specification pass explicitly reopens it
- [x] Export solve animation as MP4
- [x] Export scramble + time as shareable image
- [x] Validation evidence on `2026-06-13`: primary reverse-SSH `localhost:22022` lane, isolated Windows worktree `C:\HyperTwist_worktrees\phase10validate`, authoritative `LS_HyperTwist_ClassicReplayCapture` asset authored through `scripts/hypertwist_author_classic_cube_export_assets.py`, offscreen `MovieRenderQueue` export via `scripts/Invoke-HyperTwistClassicCubeMediaExport.ps1` plus `HyperTwistMoviePipelineReplayExecutor.py`, aggregate export report at `Saved\MediaExport\ClassicCube\validation\classic-cube-classic-cube-integration-replay-report.json`, canonical MP4 at `video\classic-cube-classic-cube-integration-replay.mp4`, still at `still\classic-cube-classic-cube-integration-replay-still.png`, share card at `classic-cube-classic-cube-integration-replay-share.png`, and post-proof `ffprobe` sanity confirming `1280x720`, `30 fps`, `85` frames, and `2.8333 s` duration after aligning the authored sequence display rate to the export frame rate
Routing correction:
- `2026-06-12`: `remotion-dev/remotion` is no longer an unproblematic direct widening lane for Phase `9B`; preserve the already-landed first-party `Phase 4R-F` outputs, but route any new donor-backed widening through restrictive custody and prefer Unreal-native export for fresh shipping work

View file

@ -417,6 +417,51 @@ Operational rules reinforced by this proof:
rerun the filters explicitly; the first filter may be the only one reflected
in the report
## Addendum - 2026-06-13 (Phase 9B Unreal-native media-export proof)
Live follow-up on `2026-06-13` established these additional facts:
- the primary `localhost:22022` lane remained healthy for the current
classic-cube media-export proof
- the validated route again used the isolated Windows tree
`C:\HyperTwist_worktrees\phase10validate`
- the authoritative replay-capture sequence asset
`LS_HyperTwist_ClassicReplayCapture.uasset` was authored there through
`scripts\hypertwist_author_classic_cube_export_assets.py`
- the authoring leg required `-NullRHI` on the reverse-SSH lane to avoid the
detached-session swapchain crash while still saving the sequence asset
- the real `MovieRenderQueue` render leg required `-RenderOffScreen`; without
it, the remote `UnrealEditor-Cmd` render process failed on the same lane with
`CreateSwapChainResult failed with error DXGI_ERROR_NOT_CURRENTLY_AVAILABLE`
- the first successful offscreen render still surfaced a real timing mismatch:
the authored sequence display rate was `60 fps` while the export plan and
custom end frame were computed at `30 fps`, which truncated the first MP4 to
`43` frames and `1.4333 s`
- aligning the authored sequence display rate to `30 fps` repaired that timing
mismatch on the next proof pass
- the final proof then produced all three canonical media artifacts plus the
aggregate report:
- MP4: `C:\HyperTwist_worktrees\phase10validate\Saved\MediaExport\ClassicCube\video\classic-cube-classic-cube-integration-replay.mp4`
- still: `C:\HyperTwist_worktrees\phase10validate\Saved\MediaExport\ClassicCube\still\classic-cube-classic-cube-integration-replay-still.png`
- share card: `C:\HyperTwist_worktrees\phase10validate\Saved\MediaExport\ClassicCube\classic-cube-classic-cube-integration-replay-share.png`
- aggregate report:
`C:\HyperTwist_worktrees\phase10validate\Saved\MediaExport\ClassicCube\validation\classic-cube-classic-cube-integration-replay-report.json`
- post-proof sanity on the exported MP4 confirmed a real `H.264` stream at
`1280x720`, `30 fps`, `85` frames, and `2.8333 s`
Operational rules reinforced by this proof:
- on the reverse-SSH headless Windows lane, split replay-export validation into
two explicit phases: asset authoring with `-NullRHI`, then real
`MovieRenderQueue` rendering with `-RenderOffScreen`
- when a replay-export end frame is computed from replay duration plus tail at
a target output frame rate, keep the authored `LevelSequence` display rate
aligned with that same frame rate or compute the end frame in sequence-rate
units instead of output-rate units
- when the export helper reports success, do one lightweight media sanity check
on the canonical MP4 rather than trusting file existence alone; current proof
used `ffprobe` to confirm codec, resolution, frame count, and duration
## Addendum - 2026-06-03 (stale-listener recovery)
Live follow-up on `2026-06-03` established this additional operational rule:

View file

@ -196,6 +196,24 @@ smoke-booting both classic-cube training maps. This extends the doctrine from
generic package proof into current structured validation-report proof on the
same primary reverse-SSH lane.
Further `2026-06-13` continuation proof on that same primary lane also closed
classic-cube `Phase 9B` Unreal-native media export on isolated worktree
`C:\HyperTwist_worktrees\phase10validate`: `scripts\hypertwist_author_classic_cube_export_assets.py`
successfully authored the authoritative replay-capture `LevelSequence` asset
through `UnrealEditor-Cmd` with `-NullRHI`, then
`scripts\Invoke-HyperTwistClassicCubeMediaExport.ps1` plus
`HyperTwistMoviePipelineReplayExecutor.py` completed the real offscreen
`MovieRenderQueue` render path with canonical MP4, still, share-card, and
aggregate report output under `Saved\MediaExport\ClassicCube\...`. The first
offscreen proof exposed a true timing bug because the authored sequence display
rate was `60 fps` while the export plan and custom end frame were computed at
`30 fps`, which truncated the initial MP4 to `43` frames; aligning the
authored sequence display rate to the export frame rate repaired the final
proof, and post-proof `ffprobe` sanity then confirmed a real `H.264`
`1280x720` / `30 fps` / `85`-frame / `2.8333 s` output. This extends the
doctrine from build/integration/package proof into current headless media-
export proof on the same primary reverse-SSH lane.
Operational reading:
- use `localhost:22022` as the primary reverse-SSH lane
@ -220,6 +238,12 @@ Operational reading:
- when validating package-helper archive/report behavior and the packaged-game
receipt already exists, `-SkipBuild` is the preferred proof path over an
unrelated full game rebuild
- for reverse-SSH/headless classic-cube replay-export validation, use
`-NullRHI` only for the asset-authoring leg and `-RenderOffScreen` for the
actual `MovieRenderQueue` render leg; they solve different failure modes
- when a replay-export end frame is derived from replay duration at a target
frame rate, keep the authored `LevelSequence` display rate aligned with that
same frame rate or compute the end frame in sequence-rate units
- when one `UnrealEditor-Cmd` `ExecCmds` string tries to queue multiple
disjoint `Automation RunTests` filters, inspect the exported report JSON or
fall back to explicit per-filter invocations; a green exit alone is not

View file

@ -167,6 +167,7 @@ repo.
| Seeded competition scramble workflow and lightweight scramble-operator shell adjuncts | Deep-source grounded retained | `cubing/cubing.js` retained lane + `cubing/mark3` / `cubing/scramble.cubing.net` successor evaluation | Source-backed successor surfaces sharpen competition-spec workflow and operator-shell expectations above the retained scramble and visualization seams, but they do not displace `cubing/cubing.js` or `cubing/twisty.js`; `scramble-display` remains comparison-only. |
| Replay shell and timeline | Implemented now | landed `cubing/twisty.js` bounded packets | First-party `HyperTwistSimulation` now owns the bounded replay-player shell, cursor/timeline transport, adapter/bootstrap, and local visualization or fallback presentation contract grounded in `cubing/twisty.js`; classic-cubing semantics remain with `cubing/cubing.js`, parser and AST ownership remain with `cubing/alg.js`, and broader browser support ownership stays with the landed browser lanes. |
| Classic-cube runtime replay capture, JSON persistence, and playback reconstruction | Implemented now | first-party current code + landed replay/training substrate | Current classic-cube runtime records move and state-snapshot replay events with timestamps during live solves, persists replay packets as `.json` through `UHyperTwistReplayPersistenceLibrary`, restores replay metadata into `AHyperTwistClassicCubeGameMode`, and reconstructs saved move streams for local playback in current code. Replay save, load, and viewer-import paths now normalize schema-light packets by repairing packet version, replay/session ids, event ids, monotonic sequences, monotonic timestamps, and derived replay summary before the packet is reused. The primary reverse-SSH `localhost:22022` Windows validation lane then refreshed the full `HyperTwist.Integration.ClassicCube` suite on isolated worktree `C:\HyperTwist_worktrees\phase10validate` on `2026-06-13`, passing `ReplayPersistenceRoundTrip`, `ReplayPersistenceNormalization`, `ReplayViewerLoad`, and `RuntimeReplaySmoke` alongside the related leaderboard integration cases. |
| Classic-cube Unreal-native replay media export | Implemented now | first-party current code + landed replay/training substrate | Current classic-cube export code now owns `MovieRenderQueue` plan generation, authoritative `LS_HyperTwist_ClassicReplayCapture` sequence authoring, Python-host executor routing, reverse-SSH-safe offscreen render invocation, canonical MP4/still/share-card output normalization, and aggregate export-report generation in current code. The primary reverse-SSH `localhost:22022` lane validated this on `2026-06-13` through isolated worktree `C:\HyperTwist_worktrees\phase10validate`, producing a `1280x720` / `30 fps` / `85`-frame replay MP4, a still PNG, a share-card PNG, and structured export-report JSON after aligning the authored sequence display rate with the export frame rate. |
| Media-export, embedded-playback, and replay-explainer reference grounding | Implemented now | `remotion-dev/remotion` retained restrictive-custody lane + first-party current code | Current live `Media Export and Replay Explainers` reference side includes five rewritten first-party contract/reference targets grounded in `remotion-dev/remotion`: embedded playback, render orchestration, media parser, explainer-studio preview/output registration, and explicit commercial-license/package-split compliance-boundary notes. This does not displace the landed `Phase 4R-F` first-party owner lane, the broader browser/spatial/media adjunct family, or the explicit package-split commercial boundary. Current routing correction: preserve the already-landed first-party outputs, but treat future donor-backed widening from `remotion-dev/remotion` as restrictive-custody and prefer Unreal-native export for fresh shipping work. |
| Browser-viewer, compact-editor, export, and docs-boundary grounding | Implemented now | `google/model-viewer` retained permissive lane + first-party current code | Current live `Browser Viewer and Asset QA` reference side includes five rewritten first-party contract/reference targets grounded in the root `google/model-viewer` lane: viewer embed, compact editor/inspection, snippet/export, renderer comparison/fidelity, and docs/demo separation. The subordinate `space-opera`, `modelviewer.dev`, and `render-fidelity-tools` source attributions are absorbed here as support-only contributors rather than separate live owner lanes. This does not displace the landed `Phase 3R-D` first-party owner lane, the separate `KhronosGroup/glTF-Sample-Viewer` standards-aware QA lane, or the already explicit `google/model-viewer/packages/shared-assets` boundary-sensitive fixture lane. |
| Standards-aware asset-validation and statistics grounding | Implemented now | `KhronosGroup/glTF-Sample-Viewer` retained permissive support lane + first-party current code | Current live `Browser Viewer and Asset QA` reference side includes one rewritten first-party target grounded in `KhronosGroup/glTF-Sample-Viewer`: standards-aware asset validation and statistics. This does not displace the landed `Phase 3R-D` first-party browser viewer owner, the separate root `google/model-viewer` viewer/reference lane, or the already explicit shared-assets boundary-sensitive fixture lane. |

View file

@ -71,6 +71,14 @@ Current consolidated milestone snapshot:
schema-light replay normalization across save/load/viewer import, and live
Windows integration proof on isolated worktree
`C:\HyperTwist_worktrees\phase10validate`
- classic-cube `Phase 9B` Unreal-native media export is now closed through
first-party `MovieRenderQueue` plan generation, authoritative
`LS_HyperTwist_ClassicReplayCapture` sequence authoring, reverse-SSH-safe
offscreen render invocation, canonical MP4/still/share-card output
normalization, aggregate export reporting, and live Windows proof on
isolated worktree `C:\HyperTwist_worktrees\phase10validate`, including a
validated `1280x720` / `30 fps` / `85`-frame replay MP4 after aligning the
authored sequence display rate to the export frame rate
- classic-cube `Phase 9C` local leaderboard stub is now closed through
first-party JSON persistence, scramble-length bucket best-time retention, HUD
display, legacy-state normalization for schema-light or duplicate local

View file

@ -0,0 +1,397 @@
param(
[Parameter(Mandatory = $true)]
[string]$ReplayPath,
[string]$ProjectRoot = 'C:\HyperTwist',
[string]$UnrealEditorCmdPath = 'C:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor-Cmd.exe',
[string]$OutputDirectory,
[string]$MapPath = '/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining',
[string]$LevelSequencePath = '/Game/HyperTwistTraining/Sequences/LS_HyperTwist_ClassicReplayCapture.LS_HyperTwist_ClassicReplayCapture',
[int]$OutputWidth = 1280,
[int]$OutputHeight = 720,
[int]$OutputFrameRate = 30,
[int]$TailDurationMs = 1500
)
$ErrorActionPreference = 'Stop'
function Format-HyperTwistMilliseconds {
param([int]$Milliseconds)
$SafeMilliseconds = [Math]::Max($Milliseconds, 0)
$Minutes = [int][Math]::Floor($SafeMilliseconds / 60000)
$Seconds = [int][Math]::Floor(($SafeMilliseconds / 1000) % 60)
$Remainder = [int]($SafeMilliseconds % 1000)
return '{0:D2}:{1:D2}.{2:D3}' -f $Minutes, $Seconds, $Remainder
}
function ConvertTo-HyperTwistFileStem {
param([string]$Value)
$Lowered = $Value.ToLowerInvariant()
$Sanitized = [regex]::Replace($Lowered, '[^a-z0-9]+', '-')
$Sanitized = [regex]::Replace($Sanitized, '-+', '-').Trim('-')
if ([string]::IsNullOrWhiteSpace($Sanitized)) {
return 'classic-cube-replay'
}
return $Sanitized
}
function Get-ClassicCubeReplayMetadata {
param([string]$ReplayFilePath)
$ReplayJson = Get-Content -LiteralPath $ReplayFilePath -Raw | ConvertFrom-Json
$ReplayId = [string]$ReplayJson.replayId
if ([string]::IsNullOrWhiteSpace($ReplayId)) {
$ReplayId = [string]$ReplayJson.sessionId
}
if ([string]::IsNullOrWhiteSpace($ReplayId)) {
$ReplayId = 'classic-cube-replay'
}
$DerivedSummary = $ReplayJson.derivedSummary
$ReplayDurationMs = 0
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.totalTimeMs) {
$ReplayDurationMs = [int]$DerivedSummary.totalTimeMs
}
foreach ($Event in @($ReplayJson.events)) {
if ($null -ne $Event.timeMs) {
$ReplayDurationMs = [Math]::Max($ReplayDurationMs, [int]$Event.timeMs)
}
}
$MoveCount = 0
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.moveCount) {
$MoveCount = [int]$DerivedSummary.moveCount
}
$Tps = 0.0
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.tps) {
$Tps = [double]$DerivedSummary.tps
}
$Result = ''
if ($null -ne $DerivedSummary -and $null -ne $DerivedSummary.result) {
$Result = [string]$DerivedSummary.result
}
$Metadata = $null
if ($null -ne $ReplayJson.annotationsJson -and -not [string]::IsNullOrWhiteSpace([string]$ReplayJson.annotationsJson)) {
$Metadata = ([string]$ReplayJson.annotationsJson | ConvertFrom-Json)
}
[pscustomobject]@{
ReplayId = $ReplayId
ReplayDurationMs = $ReplayDurationMs
MoveCount = $MoveCount
TPS = $Tps
Result = $Result
ScrambleNotation = if ($null -ne $Metadata) { [string]$Metadata.scrambleNotation } else { '' }
ScrambleLength = if ($null -ne $Metadata -and $null -ne $Metadata.scrambleLength) { [int]$Metadata.scrambleLength } else { 0 }
FinalTimeLabel = if ($Result -ieq 'dnf') { 'DNF' } else { Format-HyperTwistMilliseconds -Milliseconds $ReplayDurationMs }
}
}
function Invoke-ClassicCubeReplayExportRender {
param(
[string]$UProjectPath,
[string]$MapAssetPath,
[string]$ReplayFilePath,
[string]$OutputKind,
[string]$OutputBaseName,
[string]$OutputRoot,
[string]$SequenceAssetPath,
[int]$FrameRate,
[int]$Width,
[int]$Height,
[int]$CustomEndFrame,
[string]$ReportPath,
[double]$AutoQuitTailSeconds
)
$ExecutorArgs = @(
$UProjectPath,
$MapAssetPath,
'-game',
'-MoviePipelineLocalExecutorClass=/Script/MovieRenderPipelineCore.MoviePipelinePythonHostExecutor',
'-ExecutorPythonClass=/Engine/PythonTypes.HyperTwistMoviePipelineReplayExecutor',
"-LevelSequence=$SequenceAssetPath",
"-HyperTwistReplayPath=$ReplayFilePath",
"-HyperTwistRenderMapPath=$MapAssetPath",
'-HyperTwistDisableHud',
'-HyperTwistAutoQuitAfterReplay',
"-HyperTwistReplayAutoQuitTailSeconds=$AutoQuitTailSeconds",
"-HyperTwistOutputDirectory=$OutputRoot",
"-HyperTwistOutputBaseName=$OutputBaseName",
"-HyperTwistOutputKind=$OutputKind",
"-HyperTwistOutputWidth=$Width",
"-HyperTwistOutputHeight=$Height",
"-HyperTwistOutputFrameRate=$FrameRate",
'-HyperTwistCustomStartFrame=0',
"-HyperTwistCustomEndFrame=$CustomEndFrame",
"-HyperTwistExportReportPath=$ReportPath",
'-RenderOffScreen',
'-windowed',
"-ResX=$Width",
"-ResY=$Height",
'-unattended',
'-nop4',
'-nosplash',
'-stdout',
'-FullStdOutLogOutput',
'-log'
)
if (Test-Path -LiteralPath $ReportPath) {
Remove-Item -LiteralPath $ReportPath -Force
}
Get-ChildItem -LiteralPath $OutputRoot -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.BaseName -like "$OutputBaseName*" } |
Remove-Item -Force -ErrorAction SilentlyContinue
& $UnrealEditorCmdPath @ExecutorArgs
if ($LASTEXITCODE -ne 0) {
throw "Replay export render '$OutputKind' failed with exit code $LASTEXITCODE."
}
if (-not (Test-Path -LiteralPath $ReportPath)) {
throw "Replay export render '$OutputKind' did not produce the expected report '$ReportPath'."
}
$RenderReport = Get-Content -LiteralPath $ReportPath -Raw | ConvertFrom-Json
if (-not $RenderReport.success) {
throw "Replay export render '$OutputKind' reported failure in '$ReportPath'."
}
if (@($RenderReport.files).Count -le 0) {
throw "Replay export render '$OutputKind' produced no output files according to '$ReportPath'."
}
return $RenderReport
}
function Write-HyperTwistShareCard {
param(
[string]$BackgroundImagePath,
[string]$ShareCardPath,
[string]$Title,
[string]$Subtitle,
[string]$Footer
)
Add-Type -AssemblyName System.Drawing
$BackgroundImage = [System.Drawing.Image]::FromFile($BackgroundImagePath)
try {
$Bitmap = New-Object System.Drawing.Bitmap($BackgroundImage.Width, $BackgroundImage.Height)
$Graphics = [System.Drawing.Graphics]::FromImage($Bitmap)
try {
$Graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$Graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$Graphics.DrawImage($BackgroundImage, 0, 0, $Bitmap.Width, $Bitmap.Height)
$OverlayHeight = [int]($Bitmap.Height * 0.34)
$OverlayTop = $Bitmap.Height - $OverlayHeight
$OverlayBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(192, 8, 13, 22))
$Graphics.FillRectangle($OverlayBrush, 0, $OverlayTop, $Bitmap.Width, $OverlayHeight)
$OverlayBrush.Dispose()
$AccentPen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(210, 245, 184, 65), 6)
$Graphics.DrawLine($AccentPen, 48, $OverlayTop + 24, $Bitmap.Width - 48, $OverlayTop + 24)
$AccentPen.Dispose()
$TitleFont = New-Object System.Drawing.Font('Segoe UI Semibold', 42)
$SubtitleFont = New-Object System.Drawing.Font('Segoe UI', 20)
$FooterFont = New-Object System.Drawing.Font('Segoe UI', 16)
$PrimaryBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(255, 250, 250, 248))
$SecondaryBrush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(230, 232, 238, 246))
$Graphics.DrawString($Title, $TitleFont, $PrimaryBrush, 48, $OverlayTop + 42)
$Graphics.DrawString($Subtitle, $SubtitleFont, $SecondaryBrush, 48, $OverlayTop + 108)
$Graphics.DrawString($Footer, $FooterFont, $SecondaryBrush, 48, $OverlayTop + 146)
$TitleFont.Dispose()
$SubtitleFont.Dispose()
$FooterFont.Dispose()
$PrimaryBrush.Dispose()
$SecondaryBrush.Dispose()
$ShareDirectory = Split-Path -Parent $ShareCardPath
if (-not [string]::IsNullOrWhiteSpace($ShareDirectory)) {
New-Item -ItemType Directory -Force -Path $ShareDirectory | Out-Null
}
$Bitmap.Save($ShareCardPath, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$Graphics.Dispose()
$Bitmap.Dispose()
}
}
finally {
$BackgroundImage.Dispose()
}
}
if (-not (Test-Path -LiteralPath $UnrealEditorCmdPath)) {
throw "UnrealEditor-Cmd.exe was not found at '$UnrealEditorCmdPath'."
}
$UProjectPath = Join-Path $ProjectRoot 'UnrealHyperTwist\UnrealHyperTwist.uproject'
if (-not (Test-Path -LiteralPath $UProjectPath)) {
throw "UnrealHyperTwist project file was not found at '$UProjectPath'."
}
if (-not (Test-Path -LiteralPath $ReplayPath)) {
throw "Replay file was not found at '$ReplayPath'."
}
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
$OutputDirectory = Join-Path $ProjectRoot 'Saved\MediaExport\ClassicCube'
}
$AuthoringScriptPath = Join-Path $ProjectRoot 'scripts\hypertwist_author_classic_cube_export_assets.py'
if (-not (Test-Path -LiteralPath $AuthoringScriptPath)) {
throw "Classic-cube export authoring script was not found at '$AuthoringScriptPath'."
}
$ReplayInfo = Get-ClassicCubeReplayMetadata -ReplayFilePath $ReplayPath
$OutputBaseName = 'classic-cube-{0}' -f (ConvertTo-HyperTwistFileStem -Value $ReplayInfo.ReplayId)
$ResolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory)
$VideoDirectory = Join-Path $ResolvedOutputDirectory 'video'
$StillDirectory = Join-Path $ResolvedOutputDirectory 'still'
$ValidationDirectory = Join-Path $ResolvedOutputDirectory 'validation'
New-Item -ItemType Directory -Force -Path $VideoDirectory, $StillDirectory, $ValidationDirectory | Out-Null
$RenderEndFrame = [Math]::Max(
1,
[int][Math]::Ceiling((($ReplayInfo.ReplayDurationMs + $TailDurationMs) / 1000.0) * $OutputFrameRate)
)
$AutoQuitTailSeconds = [Math]::Round($TailDurationMs / 1000.0, 2)
$NormalizedAuthoringScriptPath = $AuthoringScriptPath -replace '\\', '/'
$AuthoringArgs = @(
$UProjectPath,
$MapPath,
"-ExecutePythonScript=$NormalizedAuthoringScriptPath",
'-NullRHI',
'-unattended',
'-nop4',
'-nosplash',
'-stdout',
'-FullStdOutLogOutput',
'-log'
)
& $UnrealEditorCmdPath @AuthoringArgs
if ($LASTEXITCODE -ne 0) {
throw "Replay export asset authoring failed with exit code $LASTEXITCODE."
}
$VideoReportPath = Join-Path $ValidationDirectory "$OutputBaseName-video-report.json"
$StillReportPath = Join-Path $ValidationDirectory "$OutputBaseName-still-report.json"
$VideoReport = Invoke-ClassicCubeReplayExportRender `
-UProjectPath $UProjectPath `
-MapAssetPath $MapPath `
-ReplayFilePath $ReplayPath `
-OutputKind 'video' `
-OutputBaseName $OutputBaseName `
-OutputRoot $VideoDirectory `
-SequenceAssetPath $LevelSequencePath `
-FrameRate $OutputFrameRate `
-Width $OutputWidth `
-Height $OutputHeight `
-CustomEndFrame $RenderEndFrame `
-ReportPath $VideoReportPath `
-AutoQuitTailSeconds $AutoQuitTailSeconds
$StillReport = Invoke-ClassicCubeReplayExportRender `
-UProjectPath $UProjectPath `
-MapAssetPath $MapPath `
-ReplayFilePath $ReplayPath `
-OutputKind 'still' `
-OutputBaseName "$OutputBaseName-still" `
-OutputRoot $StillDirectory `
-SequenceAssetPath $LevelSequencePath `
-FrameRate $OutputFrameRate `
-Width $OutputWidth `
-Height $OutputHeight `
-CustomEndFrame $RenderEndFrame `
-ReportPath $StillReportPath `
-AutoQuitTailSeconds $AutoQuitTailSeconds
$VideoFile = @($VideoReport.files | Where-Object { $_ -like '*.mp4' } | Select-Object -First 1)[0]
if ([string]::IsNullOrWhiteSpace($VideoFile) -or -not (Test-Path -LiteralPath $VideoFile)) {
throw "Replay export did not produce an MP4 output."
}
$RawVideoFile = $VideoFile
$CanonicalVideoFile = Join-Path $VideoDirectory "$OutputBaseName.mp4"
if ($RawVideoFile -ne $CanonicalVideoFile) {
Copy-Item -LiteralPath $RawVideoFile -Destination $CanonicalVideoFile -Force
}
$VideoFile = $CanonicalVideoFile
$StillFrameFile = @($StillReport.files | Where-Object { $_ -like '*.png' } | Select-Object -Last 1)[0]
if ([string]::IsNullOrWhiteSpace($StillFrameFile) -or -not (Test-Path -LiteralPath $StillFrameFile)) {
throw "Replay export did not produce a still-frame PNG output."
}
$RawStillFrameFile = $StillFrameFile
$CanonicalStillFrameFile = Join-Path $StillDirectory "$OutputBaseName-still.png"
if ($RawStillFrameFile -ne $CanonicalStillFrameFile) {
Copy-Item -LiteralPath $RawStillFrameFile -Destination $CanonicalStillFrameFile -Force
}
$StillFrameFile = $CanonicalStillFrameFile
$ShareCardPath = Join-Path $ResolvedOutputDirectory "$OutputBaseName-share.png"
$ShareSubtitle = if ($ReplayInfo.ScrambleLength -gt 0) {
'{0}-move scramble' -f $ReplayInfo.ScrambleLength
}
else {
'scramble metadata unavailable'
}
$ShareFooter = if ([string]::IsNullOrWhiteSpace($ReplayInfo.ScrambleNotation)) {
'{0} moves | {1:N2} TPS' -f $ReplayInfo.MoveCount, $ReplayInfo.TPS
}
else {
'{0} | {1} moves | {2:N2} TPS' -f $ReplayInfo.ScrambleNotation, $ReplayInfo.MoveCount, $ReplayInfo.TPS
}
Write-HyperTwistShareCard `
-BackgroundImagePath $StillFrameFile `
-ShareCardPath $ShareCardPath `
-Title $ReplayInfo.FinalTimeLabel `
-Subtitle $ShareSubtitle `
-Footer $ShareFooter
$AggregateReportPath = Join-Path $ValidationDirectory "$OutputBaseName-report.json"
$AggregateReport = [ordered]@{
reportVersion = 'ht-classic-cube-media-export-report/v1'
replayPath = (Resolve-Path -LiteralPath $ReplayPath).Path
outputBaseName = $OutputBaseName
outputWidth = $OutputWidth
outputHeight = $OutputHeight
outputFrameRate = $OutputFrameRate
replayDurationMs = $ReplayInfo.ReplayDurationMs
tailDurationMs = $TailDurationMs
renderEndFrame = $RenderEndFrame
videoPath = $VideoFile
rawVideoPath = $RawVideoFile
stillFramePath = $StillFrameFile
rawStillFramePath = $RawStillFrameFile
shareCardPath = $ShareCardPath
videoReportPath = $VideoReportPath
stillReportPath = $StillReportPath
title = $ReplayInfo.FinalTimeLabel
subtitle = $ShareSubtitle
footer = $ShareFooter
}
$AggregateReport | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $AggregateReportPath -Encoding UTF8
Write-Host "Classic-cube media export succeeded."
Write-Host "Video: $VideoFile"
Write-Host "Share card: $ShareCardPath"

View file

@ -0,0 +1,146 @@
import traceback
import unreal
MAP_PATH = "/Game/HyperTwistTraining/Maps/L_HyperTwist_ClassicTraining"
SEQUENCE_ROOT = "/Game/HyperTwistTraining/Sequences"
SEQUENCE_PATH = f"{SEQUENCE_ROOT}/LS_HyperTwist_ClassicReplayCapture"
CAMERA_LABEL = "HT_ClassicCubeReplayCaptureCamera"
AUTHOR_TAG = "HyperTwistClassicCubeReplayExport"
SEQUENCE_DURATION_FRAMES = 36000
SEQUENCE_FRAME_RATE = 30
def log(message: str) -> None:
unreal.log(f"[HyperTwistClassicCubeExportAuthoring] {message}")
def ensure_directory(directory_path: str) -> None:
if not unreal.EditorAssetLibrary.does_directory_exist(directory_path):
if not unreal.EditorAssetLibrary.make_directory(directory_path):
raise RuntimeError(f"Failed to create content directory: {directory_path}")
def require_map_loaded(level_subsystem) -> None:
if not level_subsystem.load_level(MAP_PATH):
raise RuntimeError(f"Failed to load classic training map: {MAP_PATH}")
def get_editor_world():
editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
if editor_subsystem is None:
raise RuntimeError("UnrealEditorSubsystem was not available.")
world = editor_subsystem.get_editor_world()
if world is None:
raise RuntimeError("Editor world was not available after loading the classic map.")
return world
def save_current_level_or_raise(level_subsystem, label: str) -> None:
if not level_subsystem.save_current_level():
raise RuntimeError(f"Failed to save level after {label}.")
log(f"Saved current level after {label}")
def configure_camera_actor(camera_actor) -> None:
if camera_actor is None:
raise RuntimeError("Level Sequence camera creation returned no camera actor.")
if hasattr(camera_actor, "set_actor_label"):
camera_actor.set_actor_label(CAMERA_LABEL)
existing_tags = list(camera_actor.get_editor_property("tags"))
author_tag = unreal.Name(AUTHOR_TAG)
if author_tag not in existing_tags:
existing_tags.append(author_tag)
camera_actor.set_editor_property("tags", existing_tags)
camera_actor.set_actor_location(unreal.Vector(-520.0, -520.0, 280.0), False, False)
camera_actor.set_actor_rotation(unreal.Rotator(-12.0, 42.0, 0.0), False)
camera_component = camera_actor.get_cine_camera_component()
if camera_component is not None:
camera_component.set_editor_property("current_focal_length", 55.0)
camera_component.set_editor_property("current_aperture", 4.0)
def split_asset_path(asset_path: str):
package_path, asset_name = asset_path.rsplit("/", 1)
return package_path, asset_name
def recreate_sequence_asset():
if unreal.EditorAssetLibrary.does_asset_exist(SEQUENCE_PATH):
if not unreal.EditorAssetLibrary.delete_asset(SEQUENCE_PATH):
raise RuntimeError(f"Failed to delete existing replay capture sequence: {SEQUENCE_PATH}")
log(f"Deleted existing replay capture sequence {SEQUENCE_PATH}")
package_path, asset_name = split_asset_path(SEQUENCE_PATH)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
sequence = asset_tools.create_asset(
asset_name,
package_path,
unreal.LevelSequence,
unreal.LevelSequenceFactoryNew(),
)
if sequence is None:
raise RuntimeError(f"Failed to create replay capture sequence: {SEQUENCE_PATH}")
return sequence
def author_sequence() -> None:
sequence = recreate_sequence_asset()
sequence.set_playback_start(0)
sequence.set_playback_end(SEQUENCE_DURATION_FRAMES)
if hasattr(sequence, "set_display_rate"):
sequence.set_display_rate(unreal.FrameRate(SEQUENCE_FRAME_RATE, 1))
if not unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(sequence):
raise RuntimeError(f"Failed to open replay capture sequence: {SEQUENCE_PATH}")
level_sequence_subsystem = unreal.get_editor_subsystem(
unreal.LevelSequenceEditorSubsystem
)
if level_sequence_subsystem is None:
raise RuntimeError("LevelSequenceEditorSubsystem was not available.")
log("Creating spawnable replay capture camera through LevelSequenceEditorSubsystem.")
created_camera = level_sequence_subsystem.create_camera(spawnable=True)
if isinstance(created_camera, tuple):
(_camera_binding, camera_actor) = created_camera
else:
camera_actor = created_camera
configure_camera_actor(camera_actor)
unreal.LevelSequenceEditorBlueprintLibrary.refresh_current_level_sequence()
if not unreal.EditorAssetLibrary.save_loaded_asset(sequence, False):
raise RuntimeError(f"Failed to save replay capture sequence: {SEQUENCE_PATH}")
log(f"Authored replay capture sequence {SEQUENCE_PATH}")
def main() -> None:
ensure_directory(SEQUENCE_ROOT)
log("Ensured replay capture sequence content directory.")
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
if level_subsystem is None:
raise RuntimeError("LevelEditorSubsystem was not available.")
log("Loading classic training map for replay export authoring.")
require_map_loaded(level_subsystem)
log("Classic training map loaded.")
get_editor_world()
log("Editor world resolved.")
author_sequence()
log("Classic-cube replay export assets are ready.")
if __name__ == "__main__":
try:
main()
except Exception as error:
unreal.log_error(
f"[HyperTwistClassicCubeExportAuthoring] {error}\n{traceback.format_exc()}"
)
raise