Files
UnrealEngine/Engine/Plugins/MovieScene/MovieRenderPipeline/Source/MovieRenderPipelineRenderPasses/Private/MovieGraphImageSequenceOutputNode.cpp
2025-05-18 13:04:45 +08:00

750 lines
34 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "MovieGraphImageSequenceOutputNode.h"
#include "Graph/Nodes/MovieGraphGlobalOutputSettingNode.h"
#include "Graph/Nodes/MovieGraphRenderLayerNode.h"
#include "Graph/MovieGraphDataTypes.h"
#include "Graph/MovieGraphOCIOHelper.h"
#include "Graph/MovieGraphPipeline.h"
#include "Graph/MovieGraphConfig.h"
#include "Graph/MovieGraphFilenameResolveParams.h"
#include "Graph/MovieGraphBlueprintLibrary.h"
#include "Graph/MovieRenderGraphEditorSettings.h"
#include "MoviePipelineUtils.h"
#include "MoviePipelineImageSequenceOutput.h" // for FAsyncImageQuantization
#include "MovieRenderPipelineCoreModule.h"
#include "Modules/ModuleManager.h"
#include "ImageWriteQueue.h"
#include "Misc/Paths.h"
#include "Async/TaskGraphInterfaces.h"
UMovieGraphImageSequenceOutputNode::UMovieGraphImageSequenceOutputNode()
{
ImageWriteQueue = &FModuleManager::Get().LoadModuleChecked<IImageWriteQueueModule>("ImageWriteQueue").GetWriteQueue();
}
void UMovieGraphImageSequenceOutputNode::OnAllFramesSubmittedImpl(UMovieGraphPipeline* InPipeline, TObjectPtr<UMovieGraphEvaluatedConfig>& InPrimaryJobEvaluatedGraph)
{
FinalizeFence = ImageWriteQueue->CreateFence();
}
bool UMovieGraphImageSequenceOutputNode::IsFinishedWritingToDiskImpl() const
{
// Wait until the finalization fence is reached meaning we've written everything to disk.
return Super::IsFinishedWritingToDiskImpl() && (!FinalizeFence.IsValid() || FinalizeFence.WaitFor(0));
}
FString UMovieGraphImageSequenceOutputNode::CreateFileName(
UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData,
const UMovieGraphImageSequenceOutputNode* InParentNode,
const UMovieGraphPipeline* InPipeline,
const TPair<FMovieGraphRenderDataIdentifier,
TUniquePtr<FImagePixelData>>& InRenderData,
const EImageFormat InImageFormat,
FMovieGraphResolveArgs& OutMergedFormatArgs,
FString& OutFrameTemplatedFileName) const
{
const TCHAR* Extension = TEXT("");
switch (InImageFormat)
{
case EImageFormat::PNG: Extension = TEXT("png"); break;
case EImageFormat::JPEG: Extension = TEXT("jpeg"); break;
case EImageFormat::BMP: Extension = TEXT("bmp"); break;
case EImageFormat::EXR: Extension = TEXT("exr"); break;
}
UMovieGraphGlobalOutputSettingNode* OutputSettingNode = InRawFrameData->EvaluatedConfig->GetSettingForBranch<UMovieGraphGlobalOutputSettingNode>(GlobalsPinName);
if (!OutputSettingNode)
{
return FString();
}
// Generate one string that puts the directory combined with the filename format.
FString FileNameFormatString = OutputSettingNode->OutputDirectory.Path / InParentNode->FileNameFormat;
// Insert tokens like {layer_name} as appropriate to make sure outputs don't clash with each other.
DisambiguateFilename(FileNameFormatString, InRawFrameData, InParentNode->GetFName(), InRenderData);
// Previous method is preserved for output frame number validation.
constexpr bool bIncludeRenderPass = false;
constexpr bool bTestFrameNumber = true;
constexpr bool bIncludeCameraName = false;
UE::MoviePipeline::ValidateOutputFormatString(FileNameFormatString, bIncludeRenderPass, bTestFrameNumber, bIncludeCameraName);
// Map the .ext to be specific to our output data.
TMap<FString, FString> AdditionalFormatArgs;
AdditionalFormatArgs.Add(TEXT("ext"), Extension);
UE::MovieGraph::FMovieGraphSampleState* Payload = InRenderData.Value->GetPayload<UE::MovieGraph::FMovieGraphSampleState>();
FMovieGraphFilenameResolveParams Params = FMovieGraphFilenameResolveParams::MakeResolveParams(
InRenderData.Key,
InPipeline,
InRawFrameData->EvaluatedConfig.Get(),
Payload->TraversalContext,
AdditionalFormatArgs);
// Take our string path from the Output Setting and resolve it.
const FString ResolvedFileName = UMovieGraphBlueprintLibrary::ResolveFilenameFormatArguments(FileNameFormatString, Params, OutMergedFormatArgs);
OutFrameTemplatedFileName = GetFrameTemplatedFileName(Params, FileNameFormatString, OutMergedFormatArgs);
return ResolvedFileName;
}
FString UMovieGraphImageSequenceOutputNode::GetFrameTemplatedFileName(
const FMovieGraphFilenameResolveParams& InParams, const FString& InFileNameFormatString, FMovieGraphResolveArgs& OutMergedFormatArgs) const
{
const FString FramePlaceholder = TEXT("{frame_placeholder}");
FString FrameTemplatedFormatString = InFileNameFormatString;
if (FrameTemplatedFormatString.Contains(TEXT("{frame_number}")))
{
FrameTemplatedFormatString = InFileNameFormatString.Replace(TEXT("{frame_number}"), *FramePlaceholder);
}
else if (FrameTemplatedFormatString.Contains(TEXT("{frame_number_rel}")))
{
FrameTemplatedFormatString = InFileNameFormatString.Replace(TEXT("{frame_number_rel}"), *FramePlaceholder);
}
else if (FrameTemplatedFormatString.Contains(TEXT("{frame_number_shot}")))
{
FrameTemplatedFormatString = InFileNameFormatString.Replace(TEXT("{frame_number_shot}"), *FramePlaceholder);
}
else if (FrameTemplatedFormatString.Contains(TEXT("{frame_number_shot_rel}")))
{
FrameTemplatedFormatString = InFileNameFormatString.Replace(TEXT("{frame_number_shot_rel}"), *FramePlaceholder);
}
return UMovieGraphBlueprintLibrary::ResolveFilenameFormatArguments(FrameTemplatedFormatString, InParams, OutMergedFormatArgs);
}
void UMovieGraphImageSequenceOutputNode::OnReceiveImageDataImpl(UMovieGraphPipeline* InPipeline, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, const TSet<FMovieGraphRenderDataIdentifier>& InMask)
{
check(InRawFrameData);
TArray<TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>> CompositedPasses = GetCompositedPasses(InRawFrameData);
// ToDo:
// The ImageWriteQueue is set up in a fire-and-forget manner. This means that the data needs to be placed in the WriteQueue
// as a TUniquePtr (so it can free the data when its done). Unfortunately if we have multiple output formats at once,
// we can't MoveTemp the data so we need to make a copy.
//
// Copying can be expensive (3ms @ 1080p, 12ms at 4k for a single layer image) so ideally we'd like to do it on the task graph
// but this isn't really compatible with the ImageWriteQueue API as we need the future returned by the ImageWriteQueue to happen
// in order, so that we push our futures to the main Movie Pipeline in order, otherwise when we encode files to videos they'll
// end up with frames out of order. A workaround for this would be to chain all of the send-to-imagewritequeue tasks to each
// other with dependencies, but I'm not sure that's going to scale to the potentialy high data volume going wide MRQ will eventually
// need.
// The base ImageSequenceOutputNode doesn't support any multilayer formats, so we write out each render pass separately.
for (TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>& RenderData : InRawFrameData->ImageOutputData)
{
// If this pass is composited, skip it for now
if (CompositedPasses.ContainsByPredicate([&RenderData](const TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>& CompositedPass)
{
return CompositedPass.Key == RenderData.Key;
}))
{
continue;
}
// A layer within this output data may have chosen to not be written to disk by this CDO node
if (!InMask.Contains(RenderData.Key))
{
continue;
}
checkf(RenderData.Value.IsValid(), TEXT("Unexpected empty image data: incorrectly moved or its production failed?"));
// ToDo: Certain images may require transparency, at which point
// we write out a .png instead of a .jpeg.
EImageFormat PreferredOutputFormat = OutputFormat;
constexpr bool bIncludeCDOs = false;
constexpr bool bExactMatch = true;
const UMovieGraphImageSequenceOutputNode* ParentNode = Cast<UMovieGraphImageSequenceOutputNode>(
InRawFrameData->EvaluatedConfig->GetSettingForBranch(GetClass(), RenderData.Key.RootBranchName, bIncludeCDOs, bExactMatch));
checkf(ParentNode, TEXT("Image sequence output should not exist without a parent node in the graph."));
FMovieGraphResolveArgs FinalResolvedKVPs;
FString FrameTemplatedFileName;
FString FileName = CreateFileName(InRawFrameData, ParentNode, InPipeline, RenderData, PreferredOutputFormat, FinalResolvedKVPs, FrameTemplatedFileName);
if (!ensureMsgf(!FileName.IsEmpty(), TEXT("Unexpected empty file name, skipping frame.")))
{
continue;
}
TUniquePtr<FImageWriteTask> TileImageTask = MakeUnique<FImageWriteTask>();
TileImageTask->Format = PreferredOutputFormat;
TileImageTask->CompressionQuality = 100;
TileImageTask->Filename = FileName;
// Pixel data can only be moved if there are no other active output image sequence nodes on the branch
if (GetNumFileOutputNodes(*InRawFrameData->EvaluatedConfig, RenderData.Key.RootBranchName) > 1)
{
TileImageTask->PixelData = RenderData.Value->CopyImageData();
}
else
{
TileImageTask->PixelData = RenderData.Value->MoveImageDataToNew();
}
UE::MovieGraph::FMovieGraphSampleState* Payload = RenderData.Value->GetPayload<UE::MovieGraph::FMovieGraphSampleState>();
// If the overscan isn't cropped from the final image, offset any composites to the top-left of the original frustum
FIntPoint CompositeOffset = FIntPoint::ZeroValue;
const bool bIsCropRectValid = !Payload->CropRectangle.IsEmpty();
const bool bCanCropResolution = RenderData.Value->GetSize() == Payload->OverscannedResolution;
if (ShouldCropOverscanImpl() && bIsCropRectValid && bCanCropResolution)
{
switch (RenderData.Value->GetType())
{
case EImagePixelType::Color:
TileImageTask->PixelPreProcessors.Add(TAsyncCropImage<FColor>(TileImageTask.Get(), Payload->CropRectangle));
break;
case EImagePixelType::Float16:
TileImageTask->PixelPreProcessors.Add(TAsyncCropImage<FFloat16Color>(TileImageTask.Get(), Payload->CropRectangle));
break;
case EImagePixelType::Float32:
TileImageTask->PixelPreProcessors.Add(TAsyncCropImage<FLinearColor>(TileImageTask.Get(), Payload->CropRectangle));
break;
}
}
else
{
CompositeOffset = Payload->CropRectangle.Min;
}
bool bQuantizationEncodeSRGB = true;
#if WITH_OCIO
if (FMovieGraphOCIOHelper::GenerateOcioPixelPreProcessor(Payload, InPipeline, InRawFrameData->EvaluatedConfig.Get(), ParentNode->OCIOConfiguration, ParentNode->OCIOContext, TileImageTask->PixelPreProcessors))
{
// We assume that any encoding on the output transform should be done by OCIO
bQuantizationEncodeSRGB = false;
}
#endif // WITH_OCIO
EImagePixelType PixelType = TileImageTask->PixelData->GetType();
if (bQuantizeTo8Bit && TileImageTask->PixelData->GetBitDepth() > 8u)
{
TileImageTask->PixelPreProcessors.Emplace(UE::MoviePipeline::FAsyncImageQuantization(TileImageTask.Get(), bQuantizationEncodeSRGB));
PixelType = EImagePixelType::Color;
}
// Perform compositing if any composited passes were found earlier
for (TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>& CompositedPass : CompositedPasses)
{
// This pass may not allow other passes to be composited on it
if (!Payload->bAllowsCompositing)
{
continue;
}
// This composited pass will only composite on top of renders w/ the same branch and camera
if (!CompositedPass.Key.IsBranchAndCameraEqual(RenderData.Key))
{
continue;
}
// There could be multiple renders within this branch using the composited pass, so we have to copy the image data
switch (PixelType)
{
case EImagePixelType::Color:
TileImageTask->PixelPreProcessors.Add(TAsyncCompositeImage<FColor>(CompositedPass.Value->CopyImageData(), CompositeOffset));
break;
case EImagePixelType::Float16:
TileImageTask->PixelPreProcessors.Add(TAsyncCompositeImage<FFloat16Color>(CompositedPass.Value->CopyImageData(), CompositeOffset));
break;
case EImagePixelType::Float32:
TileImageTask->PixelPreProcessors.Add(TAsyncCompositeImage<FLinearColor>(CompositedPass.Value->CopyImageData(), CompositeOffset));
break;
}
}
UE::MovieGraph::FMovieGraphOutputFutureData OutputData;
OutputData.Shot = InPipeline->GetActiveShotList()[Payload->TraversalContext.ShotIndex];
OutputData.FilePath = FileName;
OutputData.FrameTemplatedFilePath = FrameTemplatedFileName;
OutputData.DataIdentifier = RenderData.Key;
OutputData.OriginNodeClass = GetClass();
OutputData.RenderLayerIndex = Payload->RenderLayerIndex;
TFuture<bool> Future = ImageWriteQueue->Enqueue(MoveTemp(TileImageTask));
InPipeline->AddOutputFuture(MoveTemp(Future), OutputData);
}
}
TUniquePtr<FEXRImageWriteTask> UMovieGraphImageSequenceOutputNode_EXR::CreateImageWriteTask(FString InFileName, EEXRCompressionFormat InCompression, bool bMultiPart) const
{
// Ensure our OpenExrRTTI module gets loaded.
UE_CALL_ONCE([]
{
check(IsInGameThread());
FModuleManager::Get().LoadModule(TEXT("UEOpenExrRTTI"));
});
// If not using multi-part, we have to pad all layers up to the maximum resolution. If multi-part is on, different header
// data window sizes are suppported, so check the cvar to see if we should pad
const bool bPadToDataWindowSize = !bMultiPart || UE::MoviePipeline::CVarMoviePipelinePadLayersForMultiPartEXR.GetValueOnGameThread();
TUniquePtr<FEXRImageWriteTask> ImageWriteTask = MakeUnique<FEXRImageWriteTask>();
ImageWriteTask->Filename = MoveTemp(InFileName);
ImageWriteTask->bMultipart = bMultiPart;
ImageWriteTask->bPadToDataWindowSize = bPadToDataWindowSize;
ImageWriteTask->Compression = InCompression;
// ImageWriteTask->CompressionLevel is intentionally skipped and not exposed ("dwaCompressionLevel" is deprecated)
return MoveTemp(ImageWriteTask);
}
void UMovieGraphImageSequenceOutputNode_EXR::PrepareTaskGlobalMetadata(FEXRImageWriteTask& InOutImageTask, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, TMap<FString, FString>& InMetadata) const
{
// Add in hardware usage & diagnostic metadata
constexpr bool bIsGraph = true;
UE::MoviePipeline::GetHardwareUsageMetadata(InMetadata, FPaths::GetPath(InOutImageTask.Filename));
UE::MoviePipeline::GetDiagnosticMetadata(InMetadata, bIsGraph);
// Add passed in resolved metadata
for (const TPair<FString, FString>& Metadata : InMetadata)
{
InOutImageTask.FileMetadata.Emplace(Metadata.Key, Metadata.Value);
}
// Add in any metadata from the output merger frame
for (const TPair<FString, FString>& Metadata : InRawFrameData->FileMetadata)
{
InOutImageTask.FileMetadata.Add(Metadata.Key, Metadata.Value);
}
}
void UMovieGraphImageSequenceOutputNode_EXR::UpdateTaskPerLayer(
FEXRImageWriteTask& InOutImageTask,
const UMovieGraphImageSequenceOutputNode* InParentNode,
TUniquePtr<FImagePixelData> InImageData,
int32 InLayerIndex,
const FString& InLayerName,
const TMap<FString, FString>& InResolvedOCIOContext) const
{
const UE::MovieGraph::FMovieGraphSampleState* Payload = InImageData->GetPayload<UE::MovieGraph::FMovieGraphSampleState>();
bool bEnabledOCIO = false;
#if WITH_OCIO
if (FMovieGraphOCIOHelper::GenerateOcioPixelPreProcessorWithContext(Payload, InParentNode->OCIOConfiguration, InResolvedOCIOContext, InOutImageTask.PixelPreprocessors.FindOrAdd(InLayerIndex)))
{
bEnabledOCIO = true;
}
#endif // WITH_OCIO
if (InLayerIndex == 0)
{
// Add task information that is common to all layers. This metadata may be redundant with unreal/* metadata,
// but these are "standard" fields in EXR metadata.
InOutImageTask.FileMetadata.Add("owner", UE::MoviePipeline::GetJobAuthor(Payload->TraversalContext.Job));
InOutImageTask.FileMetadata.Add("comments", Payload->TraversalContext.Job->Comment);
const FIntPoint& Resolution = InImageData->GetSize();
InOutImageTask.Width = Resolution.X;
InOutImageTask.Height = Resolution.Y;
InOutImageTask.OverscanPercentage = Payload->OverscanFraction;
InOutImageTask.CropRectangle = Payload->CropRectangle;
#if WITH_OCIO
if (bEnabledOCIO)
{
UE::MoviePipeline::UpdateColorSpaceMetadata(InParentNode->OCIOConfiguration.ColorConfiguration, InOutImageTask);
}
else
#endif // WITH_OCIO
{
UE::MoviePipeline::UpdateColorSpaceMetadata(Payload->SceneCaptureSource, InOutImageTask);
}
}
if (!InLayerName.IsEmpty())
{
InOutImageTask.LayerNames.FindOrAdd(InImageData.Get(), InLayerName);
}
InOutImageTask.Layers.Add(MoveTemp(InImageData));
}
void UMovieGraphImageSequenceOutputNode_EXR::OnReceiveImageDataImpl(UMovieGraphPipeline* InPipeline, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, const TSet<FMovieGraphRenderDataIdentifier>& InMask)
{
check(InRawFrameData);
TArray<TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>> CompositedPasses = GetCompositedPasses(InRawFrameData);
for (TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>& RenderData : InRawFrameData->ImageOutputData)
{
// If this pass is composited, skip it for now
if (CompositedPasses.ContainsByPredicate([&RenderData](const TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>& CompositedPass)
{
return RenderData.Key == CompositedPass.Key;
}))
{
continue;
}
// A layer within this output data may have chosen to not be written to disk by this CDO node
if (!InMask.Contains(RenderData.Key))
{
continue;
}
checkf(RenderData.Value.IsValid(), TEXT("Unexpected empty image data: incorrectly moved or its production failed?"));
constexpr bool bIncludeCDOs = false;
constexpr bool bExactMatch = true;
const UMovieGraphImageSequenceOutputNode_EXR* ParentNode = InRawFrameData->EvaluatedConfig->GetSettingForBranch<UMovieGraphImageSequenceOutputNode_EXR>(
RenderData.Key.RootBranchName, bIncludeCDOs, bExactMatch);
checkf(ParentNode, TEXT("Single-layer EXR should not exist without a parent node in the graph."));
FMovieGraphResolveArgs ResolvedFormatArgs;
FString FrameTemplatedFileName;
FString FileName = CreateFileName(InRawFrameData, ParentNode, InPipeline, RenderData, OutputFormat, ResolvedFormatArgs, FrameTemplatedFileName);
if (!ensureMsgf(!FileName.IsEmpty(), TEXT("Unexpected empty file name, skipping frame.")))
{
continue;
}
UE::MovieGraph::FMovieGraphSampleState* Payload = RenderData.Value->GetPayload<UE::MovieGraph::FMovieGraphSampleState>();
TUniquePtr<FEXRImageWriteTask> ImageWriteTask = CreateImageWriteTask(FileName, ParentNode->Compression);
PrepareTaskGlobalMetadata(*ImageWriteTask, InRawFrameData, ResolvedFormatArgs.FileMetadata);
// No layer is equivalent to a zero-index layer
constexpr int32 LayerIndex = 0;
TUniquePtr<FImagePixelData> PixelData;
if (GetNumFileOutputNodes(*InRawFrameData->EvaluatedConfig, RenderData.Key.RootBranchName) > 1)
{
PixelData = RenderData.Value->CopyImageData();
}
else
{
PixelData = RenderData.Value->MoveImageDataToNew();
}
TMap<FString, FString> ResolvedOCIOContext = {};
#if WITH_OCIO
ResolvedOCIOContext = FMovieGraphOCIOHelper::ResolveOpenColorIOContext(
ParentNode->OCIOContext,
RenderData.Key,
InPipeline,
InRawFrameData->EvaluatedConfig.Get(),
Payload->TraversalContext
);
#endif // WITH_OCIO
UpdateTaskPerLayer(*ImageWriteTask, ParentNode, MoveTemp(PixelData), LayerIndex, FString(), ResolvedOCIOContext);
// Perform compositing if any composited passes were found earlier
for (TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>& CompositedPass : CompositedPasses)
{
// This pass may not allow other passes to be composited on it
if (!Payload->bAllowsCompositing)
{
continue;
}
// This composited pass will only composite on top of renders w/ the same branch and camera
if (CompositedPass.Key.IsBranchAndCameraEqual(RenderData.Key))
{
EImagePixelType PixelType = RenderData.Value->GetType();
// There could be multiple renders within this branch using the composited pass, so we have to copy the image data
switch (PixelType)
{
case EImagePixelType::Color:
ImageWriteTask->PixelPreprocessors.FindOrAdd(LayerIndex).Add(TAsyncCompositeImage<FColor>(CompositedPass.Value->CopyImageData(), Payload->CropRectangle.Min));
break;
case EImagePixelType::Float16:
ImageWriteTask->PixelPreprocessors.FindOrAdd(LayerIndex).Add(TAsyncCompositeImage<FFloat16Color>(CompositedPass.Value->CopyImageData(), Payload->CropRectangle.Min));
break;
case EImagePixelType::Float32:
ImageWriteTask->PixelPreprocessors.FindOrAdd(LayerIndex).Add(TAsyncCompositeImage<FLinearColor>(CompositedPass.Value->CopyImageData(), Payload->CropRectangle.Min));
break;
}
}
}
UE::MovieGraph::FMovieGraphOutputFutureData OutputFutureData;
OutputFutureData.Shot = InPipeline->GetActiveShotList()[Payload->TraversalContext.ShotIndex];
OutputFutureData.FilePath = FileName;
OutputFutureData.FrameTemplatedFilePath = FrameTemplatedFileName;
OutputFutureData.DataIdentifier = RenderData.Key;
OutputFutureData.OriginNodeClass = GetClass();
OutputFutureData.RenderLayerIndex = Payload->RenderLayerIndex;
TFuture<bool> Future = ImageWriteQueue->Enqueue(MoveTemp(ImageWriteTask));
InPipeline->AddOutputFuture(MoveTemp(Future), OutputFutureData);
}
}
void UMovieGraphImageSequenceOutputNode_MultiLayerEXR::OnReceiveImageDataImpl(UMovieGraphPipeline* InPipeline, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, const TSet<FMovieGraphRenderDataIdentifier>& InMask)
{
check(InRawFrameData);
constexpr bool bIncludeCDOs = false;
constexpr bool bExactMatch = true;
const UMovieGraphImageSequenceOutputNode_MultiLayerEXR* ParentNode = InRawFrameData->EvaluatedConfig->GetSettingForBranch<UMovieGraphImageSequenceOutputNode_MultiLayerEXR>(
UMovieGraphNode::GlobalsPinName, bIncludeCDOs, bExactMatch);
checkf(ParentNode, TEXT("Multi-Layer EXR should not exist without a parent node in the graph."));
// Generate the output config for each filename, which contains the Render IDs, the resolve args, the frame-templated filename, and the maximum resolution
// to use when outputting to the corresponding EXR file
TMap<FString, FEXROutputConfigForFilename> FilenameToRenderConfig;
GetFilenameToEXROutputConfigMappings(ParentNode, InPipeline, InRawFrameData, FilenameToRenderConfig);
// Write an EXR for each filename, which potentially contains multiple passes (render IDs).
for (TPair<FString, FEXROutputConfigForFilename>& RenderConfigForFilename : FilenameToRenderConfig)
{
const FString& Filename = RenderConfigForFilename.Key;
FEXROutputConfigForFilename& RenderConfig = RenderConfigForFilename.Value;
TUniquePtr<FEXRImageWriteTask> MultiLayerImageTask = CreateImageWriteTask(Filename, ParentNode->Compression, ParentNode->bMultipart);
PrepareTaskGlobalMetadata(*MultiLayerImageTask, InRawFrameData, RenderConfig.ResolveArgs.FileMetadata);
// Keep track of the lowest render layer index found among the layers that are included. This will be used as the index provided to the output
// future. This index is used to determine what the first render layer is when "First Render Layer Only" is turned on for displaying media
// post-render, so for multi-layer EXRs, the layer with the lowest index should be used as the index for the file.
int32 LowestRenderLayerIndex = 100000;
// Add each render pass as a layer to the EXR
bool bHasGeneratedPrimaryRGBALayer = false;
int32 LayerIndex = 0;
int32 ShotIndex = 0;
for (const FMovieGraphRenderDataIdentifier& RenderID : RenderConfig.RenderIDs)
{
const TUniquePtr<FImagePixelData>& ImageData = InRawFrameData->ImageOutputData[RenderID];
checkf(ImageData.IsValid(), TEXT("Unexpected empty image data: incorrectly moved or its production failed?"));
const UE::MovieGraph::FMovieGraphSampleState* Payload = ImageData->GetPayload<UE::MovieGraph::FMovieGraphSampleState>();
ShotIndex = Payload->TraversalContext.ShotIndex;
LowestRenderLayerIndex = FMath::Min(LowestRenderLayerIndex, Payload->RenderLayerIndex);
// The first layer that doesn't have an explicitly-specified name will have an empty layer name in the EXR -- this is the "primary"/RGBA
// layer. Otherwise, the layer name will be procedurally generated if the payload didn't specify a layer name override. Having a "primary"
// layer in the EXR expands compatibility with a number of applications that read EXRs.
FString LayerName = Payload->LayerNameOverride;
if (bHasGeneratedPrimaryRGBALayer && LayerName.IsEmpty())
{
// If there is more than one layer, then we will prefix the layer. The first layer is not prefixed (and gets inserted as RGBA)
// as most programs that handle EXRs expect the main image data to be in an unnamed layer. We only postfix with cameraname
// if there's multiple cameras, as pipelines may be already be built around the generic "one camera" support.
// TODO: The number of cameras may be inaccurate -- no camera setting in the graph yet
UMoviePipelineExecutorShot* CurrentShot = InPipeline->GetActiveShotList()[ShotIndex];
int32 NumCameras = CurrentShot->SidecarCameras.Num();
UE::MovieGraph::FMovieGraphRenderDataValidationInfo ValidationInfo = InRawFrameData->GetValidationInfo(RenderID, /*bInDiscardCompositedRenders*/ false);
TArray<FString> Tokens;
if (ValidationInfo.BranchCount > 1)
{
if (ValidationInfo.LayerCount < ValidationInfo.BranchCount)
{
Tokens.Add(RenderID.RootBranchName.ToString());
}
else
{
Tokens.Add(RenderID.LayerName);
}
}
if (ValidationInfo.ActiveBranchRendererCount > 1)
{
Tokens.Add(RenderID.RendererName);
}
if (ValidationInfo.ActiveRendererSubresourceCount > 1)
{
Tokens.Add(RenderID.SubResourceName);
}
if (NumCameras > 1)
{
Tokens.Add(RenderID.CameraName);
}
if (ensureMsgf(!Tokens.IsEmpty(), TEXT("Missing expected EXR layer token.")))
{
LayerName = Tokens[0];
for (int32 Index = 1; Index < Tokens.Num(); ++Index)
{
LayerName = FString::Printf(TEXT("%s_%s"), *LayerName, *Tokens[Index]);
}
}
}
else
{
// Don't generate a layer name. This layer will be the "primary" RGBA layer without a name.
bHasGeneratedPrimaryRGBALayer = true;
}
TUniquePtr<FImagePixelData> PixelData;
if (GetNumFileOutputNodes(*InRawFrameData->EvaluatedConfig, RenderID.RootBranchName) > 1)
{
PixelData = ImageData->CopyImageData();
}
else
{
PixelData = ImageData->MoveImageDataToNew();
}
TMap<FString, FString> ResolvedOCIOContext = {};
#if WITH_OCIO
ResolvedOCIOContext = FMovieGraphOCIOHelper::ResolveOpenColorIOContext(
ParentNode->OCIOContext,
RenderID,
InPipeline,
InRawFrameData->EvaluatedConfig.Get(),
Payload->TraversalContext
);
#endif // WITH_OCIO
UpdateTaskPerLayer(*MultiLayerImageTask, ParentNode, MoveTemp(PixelData), LayerIndex, LayerName, ResolvedOCIOContext);
// Ensure the write task uses the maximum resolution of all the layers being written
MultiLayerImageTask->Width = RenderConfig.MaximumResolution.X;
MultiLayerImageTask->Height = RenderConfig.MaximumResolution.Y;
LayerIndex++;
}
UE::MovieGraph::FMovieGraphOutputFutureData OutputFutureData;
OutputFutureData.Shot = InPipeline->GetActiveShotList()[ShotIndex];
OutputFutureData.FilePath = Filename;
OutputFutureData.FrameTemplatedFilePath = RenderConfig.FrameTemplatedFilename;
OutputFutureData.DataIdentifier = FMovieGraphRenderDataIdentifier(); // EXRs put all the render passes internally so this resolves to a ""
OutputFutureData.OriginNodeClass = GetClass();
OutputFutureData.RenderLayerIndex = LowestRenderLayerIndex;
InPipeline->AddOutputFuture(ImageWriteQueue->Enqueue(MoveTemp(MultiLayerImageTask)), OutputFutureData);
}
}
void UMovieGraphImageSequenceOutputNode_MultiLayerEXR::GetFilenameToEXROutputConfigMappings(
const UMovieGraphImageSequenceOutputNode_MultiLayerEXR* InParentNode, UMovieGraphPipeline* InPipeline,
UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, TMap<FString, FEXROutputConfigForFilename>& OutFilenameToOutputConfigs) const
{
// Merge one layer's resolve args (InNewResolveArgs) into an existing set of resolve args (InExistingResolveArgs).
auto MergeResolveArgs = [](FMovieGraphResolveArgs& InNewResolveArgs, FMovieGraphResolveArgs& InExistingResolveArgs)
{
// Covert the filename arguments to FormatNamedArguments once; this is needed by FString::Format() in the loop
FStringFormatNamedArguments NamedArguments;
for (const TPair<FString, FString>& FilenameArgument : InNewResolveArgs.FilenameArguments)
{
NamedArguments.Add(FilenameArgument.Key, FilenameArgument.Value);
}
for (TPair<FString, FString>& MetadataPair : InNewResolveArgs.FileMetadata)
{
// The metadata key and/or value may contain filename format {tokens}; resolve any of them BEFORE merging in with existing metadata. This
// is important because the metadata may contain a {token} that, once resolved, prevents a collision with an existing key.
MetadataPair.Key = FString::Format(*MetadataPair.Key, NamedArguments);
MetadataPair.Value = FString::Format(*MetadataPair.Value, NamedArguments);
// Merge in the resolved metadata into the existing metadata
InExistingResolveArgs.FileMetadata.Add(MetadataPair.Key, MetadataPair.Value);
}
// The filename arguments are not needed after merging + resolving; however, the last set of arguments is passed along anyway if they are needed.
// They aren't merged though, because they differ too much between layers to make merging of any practical usefulness (eg, {layer_name}).
InExistingResolveArgs.FilenameArguments = InNewResolveArgs.FilenameArguments;
};
// First, generate filename -> renderID mapping, and filename -> resolution mapping.
// This assumes that all render passes will have the same resolution, so we use 0 as the resolution index.
// Once we know the resolutions of all the render passes, they can be binned together into groups with the same
// resolution, and the filenames can be regenerated to ensure that passes of differing resolutions go to
// different files.
//
// This two-step process is necessary due to the flexibility in file naming, and the multi-layer nature of EXRs.
// For example, if the file name format is "{sequence_name}.{frame_number}", and the second of two branches in the
// graph has a differing resolution, only after resolving the output filenames for all outputs is a problem found;
// layers of differing resolutions will be written to the same file. Using "{layer_name}.{sequence_name}.{frame_number}"
// as the file name format would prevent the issue, but the two-step process is a generic way of approaching the
// problem.
for (const TPair<FMovieGraphRenderDataIdentifier, TUniquePtr<FImagePixelData>>& RenderPassData : InRawFrameData->ImageOutputData)
{
FString FrameTemplatedFilename;
constexpr int32 ResolutionIndex = 0;
FMovieGraphResolveArgs ResolveArgs;
const FString PreliminaryFileName = ResolveOutputFilename(InParentNode, InPipeline, InRawFrameData, RenderPassData.Key, ResolveArgs, FrameTemplatedFilename);
FEXROutputConfigForFilename& OutputConfig = OutFilenameToOutputConfigs.FindOrAdd(PreliminaryFileName);
OutputConfig.RenderIDs.Add(RenderPassData.Key);
OutputConfig.FrameTemplatedFilename = FrameTemplatedFilename;
OutputConfig.MaximumResolution.X = FMath::Max(OutputConfig.MaximumResolution.X, RenderPassData.Value->GetSize().X);
OutputConfig.MaximumResolution.Y = FMath::Max(OutputConfig.MaximumResolution.Y, RenderPassData.Value->GetSize().Y);
MergeResolveArgs(ResolveArgs, OutputConfig.ResolveArgs);
}
}
FString UMovieGraphImageSequenceOutputNode_MultiLayerEXR::ResolveOutputFilename(
const UMovieGraphImageSequenceOutputNode_MultiLayerEXR* InParentNode,
const UMovieGraphPipeline* InPipeline,
const UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData,
const FMovieGraphRenderDataIdentifier& InRenderDataIdentifier,
FMovieGraphResolveArgs& OutResolveArgs,
FString& OutFrameTemplatedFilename) const
{
const TCHAR* Extension = TEXT("exr");
constexpr bool bIncludeCDOs = true;
const UMovieGraphGlobalOutputSettingNode* OutputSettings = InRawFrameData->EvaluatedConfig->GetSettingForBranch<UMovieGraphGlobalOutputSettingNode>(InRenderDataIdentifier.RootBranchName, bIncludeCDOs);
if (!ensure(OutputSettings))
{
return FString();
}
FString FileNameFormatString = InParentNode->FileNameFormat;
// If we're writing more than one render pass out, we need to ensure the file name has the format string in it so we don't
// overwrite the same file multiple times. Burn In overlays don't count because they get composited on top of an existing file.
constexpr bool bIncludeRenderPass = false;
constexpr bool bTestFrameNumber = true;
constexpr bool bIncludeCameraName = false;
UE::MoviePipeline::ValidateOutputFormatString(FileNameFormatString, bIncludeRenderPass, bTestFrameNumber, bIncludeCameraName);
// Create specific data that needs to override
TMap<FString, FString> FormatOverrides;
FormatOverrides.Add(TEXT("render_pass"), TEXT("")); // Render Passes are included inside the exr file by named layers.
FormatOverrides.Add(TEXT("ext"), Extension);
// The layer's render data identifier is used here in the resolve. Usually this is not a problem. However, the user may include some tokens, like
// {layer_name}, that come from the identifier, which will prevent all layers from being placed in the same multi-layer EXR (because now the path
// isn't resolving to the path that other layers are resolving to). We have to assume that the user is doing this intentionally, even though it's
// a bit strange. Including the full identifier here is important so all custom metadata is resolved correctly (see
// UMovieGraphSetMetadataAttributesNode) when ResolveFilenameFormatArguments() is called.
const FMovieGraphFilenameResolveParams Params = FMovieGraphFilenameResolveParams::MakeResolveParams(
InRenderDataIdentifier, InPipeline, InRawFrameData->EvaluatedConfig.Get(), InRawFrameData->TraversalContext, FormatOverrides);
const FString FilePathFormatString = OutputSettings->OutputDirectory.Path / FileNameFormatString;
FString FinalFilePath = UMovieGraphBlueprintLibrary::ResolveFilenameFormatArguments(FilePathFormatString, Params, OutResolveArgs);
OutFrameTemplatedFilename = GetFrameTemplatedFileName(Params, FileNameFormatString, OutResolveArgs);
if (FPaths::IsRelative(FinalFilePath))
{
FinalFilePath = FPaths::ConvertRelativePathToFull(FinalFilePath);
}
return FinalFilePath;
}