// Copyright Epic Games, Inc. All Rights Reserved. #include "MovieGraphImageSequenceOutputNode.h" #include "Graph/Nodes/MovieGraphBurnInNode.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 "MoviePipelineTelemetry.h" #include "MoviePipelineUtils.h" #include "MoviePipelineImageSequenceOutput.h" // for FAsyncImageQuantization #include "MoviePipelineEXROutput.h" #include "MovieRenderPipelineCoreModule.h" #include "Modules/ModuleManager.h" #include "ImageWriteQueue.h" #include "Misc/Paths.h" #include "Async/TaskGraphInterfaces.h" #include UE_INLINE_GENERATED_CPP_BY_NAME(MovieGraphImageSequenceOutputNode) #define INJECT_BURN_IN_NODE(OUTPUT_NAME) \ if (bEnableBurnIn) \ { \ UMovieGraphOutputBurnInNode* BurnInNode = NewObject(InEvaluatedConfig); \ BurnInNode->bOverride_OutputName = true; \ BurnInNode->bOverride_FileNameFormat = true; \ BurnInNode->bOverride_OutputRestriction = true; \ BurnInNode->bOverride_bCompositeOntoFinalImage = true; \ BurnInNode->bOverride_BurnInClass = true; \ BurnInNode->OutputName = OUTPUT_NAME; \ BurnInNode->FileNameFormat = BurnInFileNameFormat; \ BurnInNode->OutputRestriction = FSoftClassPath(GetClass()); \ BurnInNode->bCompositeOntoFinalImage = bCompositeOntoFinalImage; \ BurnInNode->BurnInClass = BurnInClass; \ \ OutInjectedNodes.Add(BurnInNode); \ } \ #define INJECT_BURN_IN_MULTILAYER_NODE(OUTPUT_NAME) \ if (bEnableBurnIn) \ { \ UMovieGraphOutputBurnInNode* BurnInNode = NewObject(InEvaluatedConfig); \ BurnInNode->bOverride_OutputName = true; \ BurnInNode->bOverride_FileNameFormat = true; \ BurnInNode->bOverride_OutputRestriction = true; \ BurnInNode->bOverride_bCompositeOntoFinalImage = true; \ BurnInNode->bOverride_BurnInClass = true; \ BurnInNode->bOverride_LayerNameFormat = true; \ BurnInNode->OutputName = OUTPUT_NAME; \ BurnInNode->FileNameFormat = BurnInFileNameFormat; \ BurnInNode->OutputRestriction = FSoftClassPath(GetClass()); \ BurnInNode->bCompositeOntoFinalImage = false; \ BurnInNode->BurnInClass = BurnInClass; \ BurnInNode->LayerNameFormat = BurnInLayerNameFormat; \ \ OutInjectedNodes.Add(BurnInNode); \ } \ namespace UE::MovieGraph { void UpdateMetadataLayerNamesToMatchExrLayerNames(const FMovieGraphRenderDataIdentifier& InIdentifier, const FString& LayerName, TMap& InOutMetadata) { // We rename the metadata in the file when we write it to disk, because there's no other way to know what metadata goes with the actual layer name in the exr, // and we don't want to require complex string manipulation in pipeline tools, so we rename `unreal////foo` to just // `unreal/layerData//foo`, where exrLayerName comes from this function that decided if the current layer is either "rgba" or something more complex. TMap NewMetadataNames; TArray OldMetadataKeys; const FString MetadataPrefixForLayer = UE::MoviePipeline::GetMetadataPrefixPath(InIdentifier); for (const TPair& KVP : InOutMetadata) { if (KVP.Key.StartsWith(MetadataPrefixForLayer)) { const FString NewMetadataPrefix = FString::Printf(TEXT("unreal/layerData/%s"), LayerName.IsEmpty() ? TEXT("rgba") : *LayerName); const FString OldMetadataPostfix = KVP.Key.RightChop(MetadataPrefixForLayer.Len()); const FString NewKey = NewMetadataPrefix + OldMetadataPostfix; NewMetadataNames.Add(NewKey, KVP.Value); // We want to remove the old keys to prevent confusion OldMetadataKeys.Add(KVP.Key); } } // Remove the old keys from the map for (const FString& OldKey : OldMetadataKeys) { InOutMetadata.Remove(OldKey); } // Add the renamed ones InOutMetadata.Append(NewMetadataNames); } } UMovieGraphImageSequenceOutputNode::UMovieGraphImageSequenceOutputNode() { ImageWriteQueue = &FModuleManager::Get().LoadModuleChecked("ImageWriteQueue").GetWriteQueue(); } void UMovieGraphImageSequenceOutputNode::OnAllFramesSubmittedImpl(UMovieGraphPipeline* InPipeline, TObjectPtr& 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>& 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(GlobalsPinName); if (!OutputSettingNode) { return FString(); } const UE::MovieGraph::FMovieGraphSampleState* Payload = InRenderData.Value->GetPayload(); // The file name format usually comes from the output node directly, but the payload has a chance to override it. const FString FinalFileNameFormat = !Payload->FilenameFormatOverride.IsEmpty() ? Payload->FilenameFormatOverride : InParentNode->FileNameFormat; // Generate one string that puts the directory combined with the filename format. FString FileNameFormatString = OutputSettingNode->OutputDirectory.Path / FinalFileNameFormat; // Insert tokens like {layer_name} as appropriate to make sure outputs don't clash with each other. DisambiguateFilename(FileNameFormatString, InRawFrameData, InParentNode, 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 AdditionalFormatArgs; AdditionalFormatArgs.Add(TEXT("ext"), Extension); 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); } // If time dilation is being used, the parameters will ask ResolveFilenameFormatArguments to warn the user if there's no _rel frame number // found, but we're intentionally overriding them above to be able to replace them with a completely unrelated token, so we don't actually // want that warning to be produced. FMovieGraphFilenameResolveParams ParamsCopy = InParams; ParamsCopy.bForceRelativeFrameNumbers = false; return UMovieGraphBlueprintLibrary::ResolveFilenameFormatArguments(FrameTemplatedFormatString, ParamsCopy, OutMergedFormatArgs); } void UMovieGraphImageSequenceOutputNode::OnReceiveImageDataImpl(UMovieGraphPipeline* InPipeline, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, const TSet& InMask) { // ------------------------ // This method is called on the CDO! // ------------------------ check(InRawFrameData); TArray>> 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. // Do a first pass to determine which layers/payloads should actually be written by this output node. This needs to be done upfront so we can // provide a list of payloads output to this node to the filename disambiguation logic. TMap> PayloadsToWrite; for (TPair>& RenderData : InRawFrameData->ImageOutputData) { checkf(RenderData.Value.IsValid(), TEXT("Unexpected empty image data: incorrectly moved or its production failed?")); UE::MovieGraph::FMovieGraphSampleState* Payload = RenderData.Value->GetPayload(); // This payload may have opted out of being written by this output type. if (!Payload->OutputTypeRestrictions.IsEmpty() && !Payload->OutputTypeRestrictions.Contains(FSoftClassPath(GetClass()))) { continue; } // If this pass is composited, skip it for now if (CompositedPasses.ContainsByPredicate([&RenderData](const TPair>& 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; } constexpr bool bIncludeCDOs = false; constexpr bool bExactMatch = true; const UMovieGraphImageSequenceOutputNode* ParentNode = Cast( 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.")); PayloadsToWrite.FindOrAdd(ParentNode).Add(Payload); } // NodeInstanceToPayloads needs to be populated before the loop below; it's used in filename disambiguation InRawFrameData->NodeInstanceToPayloads.Append(PayloadsToWrite); // The base ImageSequenceOutputNode doesn't support any multilayer formats, so we write out each render pass separately. for (TPair>& RenderData : InRawFrameData->ImageOutputData) { constexpr bool bIncludeCDOs = false; constexpr bool bExactMatch = true; const UMovieGraphImageSequenceOutputNode* ParentNode = Cast( InRawFrameData->EvaluatedConfig->GetSettingForBranch(GetClass(), RenderData.Key.RootBranchName, bIncludeCDOs, bExactMatch)); if (!IsValid(ParentNode)) { continue; } TArray* EligiblePayloads = PayloadsToWrite.Find(ParentNode); if (!EligiblePayloads) { continue; } // Only write payloads that have been vetted by the prior loop and placed in NodeInstanceToPayloads. UE::MovieGraph::FMovieGraphSampleState* Payload = RenderData.Value->GetPayload(); if (!EligiblePayloads->Contains(Payload)) { continue; } FMovieGraphResolveArgs FinalResolvedKVPs; FString FrameTemplatedFileName; FString FileName = CreateFileName(InRawFrameData, ParentNode, InPipeline, RenderData, OutputFormat, FinalResolvedKVPs, FrameTemplatedFileName); if (!ensureMsgf(!FileName.IsEmpty(), TEXT("Unexpected empty file name, skipping frame."))) { continue; } TUniquePtr TileImageTask = MakeUnique(); TileImageTask->Format = OutputFormat; 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(); } // 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(TileImageTask.Get(), Payload->CropRectangle)); break; case EImagePixelType::Float16: TileImageTask->PixelPreProcessors.Add(TAsyncCropImage(TileImageTask.Get(), Payload->CropRectangle)); break; case EImagePixelType::Float32: TileImageTask->PixelPreProcessors.Add(TAsyncCropImage(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>& 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; } UE::MovieGraph::FMovieGraphSampleState* CompositePayload = CompositedPass.Value->GetPayload(); // This composite may have opted out of being written by this output type. if (!CompositePayload->OutputTypeRestrictions.IsEmpty() && !CompositePayload->OutputTypeRestrictions.Contains(FSoftClassPath(GetClass()))) { 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(CompositedPass.Value->CopyImageData(), CompositeOffset)); break; case EImagePixelType::Float16: TileImageTask->PixelPreProcessors.Add(TAsyncCompositeImage(CompositedPass.Value->CopyImageData(), CompositeOffset)); break; case EImagePixelType::Float32: TileImageTask->PixelPreProcessors.Add(TAsyncCompositeImage(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 Future = ImageWriteQueue->Enqueue(MoveTemp(TileImageTask)); InPipeline->AddOutputFuture(MoveTemp(Future), OutputData); } } #if WITH_UNREALEXR TUniquePtr 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 ImageWriteTask = MakeUnique(); 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& 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& Metadata : InMetadata) { InOutImageTask.FileMetadata.Emplace(Metadata.Key, Metadata.Value); } // Add in any metadata from the output merger frame for (const TPair& Metadata : InRawFrameData->FileMetadata) { InOutImageTask.FileMetadata.Add(Metadata.Key, Metadata.Value); } } void UMovieGraphImageSequenceOutputNode_EXR::UpdateTaskPerLayer( FEXRImageWriteTask& InOutImageTask, const UMovieGraphImageSequenceOutputNode* InParentNode, TUniquePtr InImageData, int32 InLayerIndex, const FString& InLayerName, const TMap& InResolvedOCIOContext) const { const UE::MovieGraph::FMovieGraphSampleState* Payload = InImageData->GetPayload(); 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)); } #endif // WITH_UNREALEXR #if WITH_UNREALEXR void UMovieGraphImageSequenceOutputNode_EXR::OnReceiveImageDataImpl(UMovieGraphPipeline* InPipeline, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, const TSet& InMask) { check(InRawFrameData); TArray>> CompositedPasses = GetCompositedPasses(InRawFrameData); // Do a first pass to determine which layers/payloads should actually be written by this output node. This needs to be done upfront so we can // provide a list of payloads output by this node to the filename disambiguation logic. TMap> PayloadsToWrite; for (TPair>& RenderData : InRawFrameData->ImageOutputData) { checkf(RenderData.Value.IsValid(), TEXT("Unexpected empty image data: incorrectly moved or its production failed?")); UE::MovieGraph::FMovieGraphSampleState* Payload = RenderData.Value->GetPayload(); // This payload may have opted out of being written by this output type. if (!Payload->OutputTypeRestrictions.IsEmpty() && !Payload->OutputTypeRestrictions.Contains(FSoftClassPath(GetClass()))) { continue; } // If this pass is composited, skip it for now if (CompositedPasses.ContainsByPredicate([&RenderData](const TPair>& 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; } constexpr bool bIncludeCDOs = false; constexpr bool bExactMatch = true; const UMovieGraphImageSequenceOutputNode* ParentNode = Cast( 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.")); PayloadsToWrite.FindOrAdd(ParentNode).Add(Payload); } // NodeInstanceToPayloads needs to be populated before the loop below; it's used in filename disambiguation InRawFrameData->NodeInstanceToPayloads.Append(PayloadsToWrite); for (TPair>& RenderData : InRawFrameData->ImageOutputData) { constexpr bool bIncludeCDOs = false; constexpr bool bExactMatch = true; const UMovieGraphImageSequenceOutputNode_EXR* ParentNode = InRawFrameData->EvaluatedConfig->GetSettingForBranch( RenderData.Key.RootBranchName, bIncludeCDOs, bExactMatch); if (!IsValid(ParentNode)) { continue; } TArray* EligiblePayloads = PayloadsToWrite.Find(ParentNode); if (!EligiblePayloads) { continue; } // Only write payloads that have been vetted by the prior loop and placed in NodeInstanceToPayloads. UE::MovieGraph::FMovieGraphSampleState* Payload = RenderData.Value->GetPayload(); if (!EligiblePayloads->Contains(Payload)) { continue; } 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; } // The pass may be need to be written out with lossless compression (eg, Object ID forces this on). const EEXRCompressionFormat CompressionType = Payload->bForceLosslessCompression ? ParentNode->LosslessCompression : ParentNode->Compression; TUniquePtr ImageWriteTask = CreateImageWriteTask(FileName, CompressionType); PrepareTaskGlobalMetadata(*ImageWriteTask, InRawFrameData, ResolvedFormatArgs.FileMetadata); // No layer is equivalent to a zero-index layer constexpr int32 LayerIndex = 0; TUniquePtr PixelData; if (GetNumFileOutputNodes(*InRawFrameData->EvaluatedConfig, RenderData.Key.RootBranchName) > 1) { PixelData = RenderData.Value->CopyImageData(); } else { PixelData = RenderData.Value->MoveImageDataToNew(); } TMap 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>& CompositedPass : CompositedPasses) { // This pass may not allow other passes to be composited on it if (!Payload->bAllowsCompositing) { continue; } UE::MovieGraph::FMovieGraphSampleState* CompositePayload = CompositedPass.Value->GetPayload(); // This composite may have opted out of being written by this output type. if (!CompositePayload->OutputTypeRestrictions.IsEmpty() && !CompositePayload->OutputTypeRestrictions.Contains(FSoftClassPath(GetClass()))) { 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(CompositedPass.Value->CopyImageData(), Payload->CropRectangle.Min)); break; case EImagePixelType::Float16: ImageWriteTask->PixelPreprocessors.FindOrAdd(LayerIndex).Add(TAsyncCompositeImage(CompositedPass.Value->CopyImageData(), Payload->CropRectangle.Min)); break; case EImagePixelType::Float32: ImageWriteTask->PixelPreprocessors.FindOrAdd(LayerIndex).Add(TAsyncCompositeImage(CompositedPass.Value->CopyImageData(), Payload->CropRectangle.Min)); break; } } } // Rename the per-layer metadata to match the EXR layer names to make fetching metadata simple string lookups. UE::MovieGraph::UpdateMetadataLayerNamesToMatchExrLayerNames(RenderData.Key, TEXT(""), ImageWriteTask->FileMetadata); 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 Future = ImageWriteQueue->Enqueue(MoveTemp(ImageWriteTask)); InPipeline->AddOutputFuture(MoveTemp(Future), OutputFutureData); } } #endif // WITH_UNREALEXR void UMovieGraphImageSequenceOutputNode_EXR::InjectNodesPostEvaluation(const FName& InBranchName, UMovieGraphEvaluatedConfig* InEvaluatedConfig, TArray& OutInjectedNodes) { INJECT_BURN_IN_NODE(TEXT("EXR")) } void UMovieGraphImageSequenceOutputNode_EXR::UpdateTelemetry(FMoviePipelineShotRenderTelemetry* InTelemetry) const { InTelemetry->bUsesEXR = true; } #if WITH_UNREALEXR void UMovieGraphImageSequenceOutputNode_MultiLayerEXR::OnReceiveImageDataImpl(UMovieGraphPipeline* InPipeline, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, const TSet& InMask) { auto GetPayloadCompressionType = []( const UE::MovieGraph::FMovieGraphSampleState* InPayload, const UMovieGraphImageSequenceOutputNode_MultiLayerEXR* InParentNode) -> EEXRCompressionFormat { return InPayload->bForceLosslessCompression ? InParentNode->LosslessCompression : InParentNode->Compression; }; check(InRawFrameData); constexpr bool bIncludeCDOs = false; constexpr bool bExactMatch = true; const UMovieGraphImageSequenceOutputNode_MultiLayerEXR* ParentNode = InRawFrameData->EvaluatedConfig->GetSettingForBranch( 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 FilenameToRenderConfig; GetFilenameToEXROutputConfigMappings(ParentNode, InPipeline, InRawFrameData, FilenameToRenderConfig); // Write an EXR for each filename, which potentially contains multiple passes (render IDs). for (TPair& RenderConfigForFilename : FilenameToRenderConfig) { const FString& Filename = RenderConfigForFilename.Key; FEXROutputConfigForFilename& RenderConfig = RenderConfigForFilename.Value; // If all layers are not using the same compression type, force multipart on. bool bUseMultipart = ParentNode->bMultipart; if (!bUseMultipart) { EEXRCompressionFormat LastCompressionType = EEXRCompressionFormat::Max; for (const FMovieGraphRenderDataIdentifier& RenderID : RenderConfig.RenderIDs) { const TUniquePtr& 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(); const EEXRCompressionFormat CompressionType = GetPayloadCompressionType(Payload, ParentNode); if (LastCompressionType == EEXRCompressionFormat::Max) { LastCompressionType = CompressionType; } else if (CompressionType != LastCompressionType) { MRG_LOG_ONCE_PER_RENDER(ForceMultiPart, LogMovieRenderPipeline, Warning, TEXT("An EXR is being generated that containins layers with differing compression types (can happen especially when generating Object IDs or using PPMs). Forcing EXR multipart ON.")); bUseMultipart = true; break; } } } TUniquePtr MultiLayerImageTask = CreateImageWriteTask(Filename, ParentNode->Compression, bUseMultipart); 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& 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(); // This payload may have opted out of being written by this output type. if (!Payload->OutputTypeRestrictions.IsEmpty() && !Payload->OutputTypeRestrictions.Contains(FSoftClassPath(GetClass()))) { continue; } ShotIndex = Payload->TraversalContext.ShotIndex; LowestRenderLayerIndex = FMath::Min(LowestRenderLayerIndex, Payload->RenderLayerIndex); // If using multipart, specify the compression type for each layer. if (bUseMultipart) { const EEXRCompressionFormat LayerCompressionType = GetPayloadCompressionType(Payload, ParentNode); MultiLayerImageTask->CompressionByLayer.Add(LayerCompressionType); } // 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 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 PixelData; if (GetNumFileOutputNodes(*InRawFrameData->EvaluatedConfig, RenderID.RootBranchName) > 1) { PixelData = ImageData->CopyImageData(); } else { PixelData = ImageData->MoveImageDataToNew(); } TMap 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; // Rename the per-layer metadata to match the EXR layer names to make fetching metadata simple string lookups. UE::MovieGraph::UpdateMetadataLayerNamesToMatchExrLayerNames(RenderID, LayerName, MultiLayerImageTask->FileMetadata); 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); } } #endif // WITH_UNREALEXR void UMovieGraphImageSequenceOutputNode_MultiLayerEXR::InjectNodesPostEvaluation(const FName& InBranchName, UMovieGraphEvaluatedConfig* InEvaluatedConfig, TArray& OutInjectedNodes) { INJECT_BURN_IN_MULTILAYER_NODE(TEXT("MULTI_EXR")) } void UMovieGraphImageSequenceOutputNode_MultiLayerEXR::UpdateTelemetry(FMoviePipelineShotRenderTelemetry* InTelemetry) const { InTelemetry->bUsesMultiEXR = true; } void UMovieGraphImageSequenceOutputNode_MultiLayerEXR::GetFilenameToEXROutputConfigMappings( const UMovieGraphImageSequenceOutputNode_MultiLayerEXR* InParentNode, UMovieGraphPipeline* InPipeline, UE::MovieGraph::FMovieGraphOutputMergerFrame* InRawFrameData, TMap& 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& FilenameArgument : InNewResolveArgs.FilenameArguments) { NamedArguments.Add(FilenameArgument.Key, FilenameArgument.Value); } for (TPair& 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>& 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(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 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, FilePathFormatString, OutResolveArgs); if (FPaths::IsRelative(FinalFilePath)) { FinalFilePath = FPaths::ConvertRelativePathToFull(FinalFilePath); } return FinalFilePath; } void UMovieGraphImageSequenceOutputNode_BMP::InjectNodesPostEvaluation(const FName& InBranchName, UMovieGraphEvaluatedConfig* InEvaluatedConfig, TArray& OutInjectedNodes) { INJECT_BURN_IN_NODE(TEXT("BMP")) } void UMovieGraphImageSequenceOutputNode_BMP::UpdateTelemetry(FMoviePipelineShotRenderTelemetry* InTelemetry) const { InTelemetry->bUsesBMP = true; } void UMovieGraphImageSequenceOutputNode_JPG::InjectNodesPostEvaluation(const FName& InBranchName, UMovieGraphEvaluatedConfig* InEvaluatedConfig, TArray& OutInjectedNodes) { INJECT_BURN_IN_NODE(TEXT("JPG")) } void UMovieGraphImageSequenceOutputNode_JPG::UpdateTelemetry(FMoviePipelineShotRenderTelemetry* InTelemetry) const { InTelemetry->bUsesJPG = true; } void UMovieGraphImageSequenceOutputNode_PNG::InjectNodesPostEvaluation(const FName& InBranchName, UMovieGraphEvaluatedConfig* InEvaluatedConfig, TArray& OutInjectedNodes) { INJECT_BURN_IN_NODE(TEXT("PNG")) } void UMovieGraphImageSequenceOutputNode_PNG::UpdateTelemetry(FMoviePipelineShotRenderTelemetry* InTelemetry) const { InTelemetry->bUsesPNG = true; }