diff --git a/UnrealHyperTwist/Content/HyperTwistTraining/Sequences/LS_HyperTwist_ClassicReplayCapture.uasset b/UnrealHyperTwist/Content/HyperTwistTraining/Sequences/LS_HyperTwist_ClassicReplayCapture.uasset new file mode 100644 index 0000000..60e4009 Binary files /dev/null and b/UnrealHyperTwist/Content/HyperTwistTraining/Sequences/LS_HyperTwist_ClassicReplayCapture.uasset differ diff --git a/UnrealHyperTwist/Content/Python/HyperTwistMoviePipelineReplayExecutor.py b/UnrealHyperTwist/Content/Python/HyperTwistMoviePipelineReplayExecutor.py new file mode 100644 index 0000000..f6c7271 --- /dev/null +++ b/UnrealHyperTwist/Content/Python/HyperTwistMoviePipelineReplayExecutor.py @@ -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 ''}, " + 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() diff --git a/UnrealHyperTwist/Content/Python/init_unreal.py b/UnrealHyperTwist/Content/Python/init_unreal.py new file mode 100644 index 0000000..175e0ca --- /dev/null +++ b/UnrealHyperTwist/Content/Python/init_unreal.py @@ -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}" + ) diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp index 27bbe9c..64b5f9b 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistSimulation/HyperTwistClassicCubeGameMode.cpp @@ -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(DeltaSeconds), 0.0); + if (ReplayAutoQuitCountdownSeconds > 0.0) + { + return; + } + + bReplayAutoQuitPending = false; + ReplayAutoQuitCountdownSeconds = -1.0; + FGenericPlatformMisc::RequestExit(false); +} + FString AHyperTwistClassicCubeGameMode::ResolveActivePuzzleId() const { if (bHasReplayMetadata diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.cpp index ae4e8d9..103a782 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.cpp @@ -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 + 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(ReplayDurationMs + TailDurationMs) + * static_cast(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; +} diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h index 6ab144c..0257785 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistSimulation/HyperTwistClassicCubeGameMode.h @@ -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; }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.h index 456ed42..81519d0 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistTrainingMediaExportLibrary.h @@ -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 + ); }; diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp index 159bbdd..5477b12 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Tests/HyperTwistClassicCubeIntegrationTest.cpp @@ -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", diff --git a/UnrealHyperTwist/UnrealHyperTwist.uproject b/UnrealHyperTwist/UnrealHyperTwist.uproject index 514b0e1..20effe9 100644 --- a/UnrealHyperTwist/UnrealHyperTwist.uproject +++ b/UnrealHyperTwist/UnrealHyperTwist.uproject @@ -57,6 +57,13 @@ { "Name": "WebBrowserWidget", "Enabled": true + }, + { + "Name": "MovieRenderPipeline", + "Enabled": true, + "TargetAllowList": [ + "Editor" + ] } ] } diff --git a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md index 2bf1afc..cd4e7c6 100644 --- a/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md +++ b/docs/ops/HYPERTWIST_COMPREHENSIVE_RECONSTRUCTION_ROADMAP_2026-06-10.md @@ -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 diff --git a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md index 846c428..4cc7663 100644 --- a/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md +++ b/docs/ops/HYPERTWIST_REVERSE_SSH_WINDOWS_BUILD_LANE_VERIFICATION_2026-06-01.md @@ -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: diff --git a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md index ffe7c2f..8b360f7 100644 --- a/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md +++ b/docs/ops/HYPERTWIST_UNREAL_BUILD_VALIDATION_REQUIREMENT_2026-06-01.md @@ -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 diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md index de8b2e4..9a515c6 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/FEATURE_REGISTRY.md @@ -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. | diff --git a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md index cc15da6..79afe1e 100644 --- a/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md +++ b/docs/v6_5_deep_manual_pack/HyperTwist/ROADMAP.md @@ -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 diff --git a/scripts/Invoke-HyperTwistClassicCubeMediaExport.ps1 b/scripts/Invoke-HyperTwistClassicCubeMediaExport.ps1 new file mode 100644 index 0000000..be007ce --- /dev/null +++ b/scripts/Invoke-HyperTwistClassicCubeMediaExport.ps1 @@ -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" diff --git a/scripts/hypertwist_author_classic_cube_export_assets.py b/scripts/hypertwist_author_classic_cube_export_assets.py new file mode 100644 index 0000000..3b56fb7 --- /dev/null +++ b/scripts/hypertwist_author_classic_cube_export_assets.py @@ -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