Close Phase 9B Unreal-native media export
This commit is contained in:
parent
56cd8e7802
commit
1558d59e54
16 changed files with 1593 additions and 4 deletions
Binary file not shown.
|
|
@ -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()
|
||||
23
UnrealHyperTwist/Content/Python/init_unreal.py
Normal file
23
UnrealHyperTwist/Content/Python/init_unreal.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,13 @@
|
|||
{
|
||||
"Name": "WebBrowserWidget",
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Name": "MovieRenderPipeline",
|
||||
"Enabled": true,
|
||||
"TargetAllowList": [
|
||||
"Editor"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
397
scripts/Invoke-HyperTwistClassicCubeMediaExport.ps1
Normal file
397
scripts/Invoke-HyperTwistClassicCubeMediaExport.ps1
Normal 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"
|
||||
146
scripts/hypertwist_author_classic_cube_export_assets.py
Normal file
146
scripts/hypertwist_author_classic_cube_export_assets.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue