Привет, Хабражители!

Это моя проба пера, вполне возможно, будет комом, пардоньте.

Да, это перевод моего собственного туториала на форуме EpicGames, на английском можно почитать тут.

Довольно часто бывает ситуация, когда нужно залить картинки, сгенерированные в UE на сервер, у движка есть все части инструмента, но готового решения для отправки картинок в теле запроса, к сожалению, нет. Что ж, попробуем запилить свой велосипед, благо, это велосипед не сложный, игрушечный, можно сказать.

Статья разделена на две части, серверную и, собственно, для чего это всё бумагомарание – реализация на C++ в Unreal. Простые вещи, но иногда не совсем очевидные для далеких от серверной разработки людей (таких как я).

Сервер

Для тех, кто умеет в back-end, можно сразу переходить ко второй части.

Код можно скачать в этой репе.

  • Для клонирования репозитория, можно открыть командную строку, перейти в нужную вам папку, и выполнить данную команду: git clone https://github.com/RadAlex/express-upload-template.git

  • Установите node js

  • В командной строке (или в VS code terminal) перейдите в папку с проектом и выполните команду npm install

  • Следом выполните команду npm run start для запуска сервера

  • Откройте браузер и введите адрес http://127.0.0.1:3000/upload

  • В открывшейся форме, выберите файл на диске, нажав на кнопку Browse, и нажмите Upload

  • В подпапке проекта /public, вы увидите свой файл.

Небольшое объяснения кода, для тех, кто не знаком:

Сначала идут зависимости (include в парадигме С++).

Там же мы определяем константу порта, на котором сервер будет слушать команды и инициализируем модули.

Для того, чтобы мы могли обратиться к серверу, нужно указать, так называемые, endpoints, это адреса, по которым сервер будет получать и отдавать информацию, запросы протокола HTTP бывают нескольких типов, здесь мы будем использовать только GET и POST.

Собственно, GET реализуется командой app.get("/", function (req, res) {  res.send("Hey!");});

Если обратиться к серверу, по адресу http://127.0.0.1:3000/ то он вернет текст “Hey!”.

Команда ниже уже отправляет не текст, а HTML файл, upload.html

app.get("/upload", function (req, res) {

  res.sendFile(path.join(__dirname, "/public/upload.html"));

});

 

Endpoint для заливки файла реализуется с помощью команды app.post().

app.post("/upload", async function (req, res) {… }).

При отправке, файл будет находиться в теле запроса, и его можно получить из массива req.files (ведь их может быть больше одного).

Проведя простенькую валидацию запроса, мы сохраняем файл в переменную

const uploadedFile = req.files.uploadFile.

Создаем переменную, содержащую путь сохранения (имя файла получаем с помощью uploadedFile.name):

const uploadPath = __dirname + "/public/" + filename;

И сохраняем нашу картинку командой mv :

    uploadedFile.mv(uploadPath, function (err) {…})

 

Запускается наш сервер командой:

app.listen(PORT, () => {

  console.log(app is running on PORT ${PORT});

});

Теперь к самому интересному, для чего мы тут собрались.

Unreal

Код писался для версии 4.27.2, но я не вижу причин, почему оно не запустится в 5х версиях.

Тут и тут можно найти хорошие туториалы на эту тему, но в первой нет описания как добавить картинку в запрос, а вторая зависит от модуля MimeUpload, который я не смог найти.

Весь код будет находиться в классе GameModeBase, что не есть гут, а для простоты. Ведь это туториал, тут будут объяснения простейших вещей, и чем проще следовать ему, тем проще усвоить информацию. Если объяснения покажутся вам вводящими в заблуждение, ну или просто бредом наглой ложью неверными, буду признателен за коррекцию.

Итак, начнем:

Создайте пустой проект (С++ или blueprint, не важно)

Добавьте С++ класс с помощью команды (File-> Add new C++ class)

Назовите как хотите, мой носит ужасно оригинальное имя: MyGameModeBase

Отредактируйте заголовочный файл как в коде ниже (можно прямо скопипастить код, если вы все пишите в GameMode), не забудьте изменить SCREENSHOTS_API на название вашего проекта (все буквы заглавные),  если вы назвали класс по-другому, еще нужно будет заменить название AMyGameModeBase на свое

MyGameModeBase.h
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"


#include "Interfaces/IHttpResponse.h"
#include "HTTPModule.h"

#include "MyGameModeBase.generated.h"

/**
 * 
 */
UCLASS()
class SCREENSHOTS_API AMyGameModeBase : public AGameModeBase
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable)
		void TakeScreenshot();

private:
	// This is the filename for screenshot, fixed for simplicity
	FString ScreenshotFileName = "D:/shots/screenshot.png";

	// These will be used for request body in the code
	FString BoundaryLabel = FString();
	FString BoundaryBegin = FString();
	FString BoundaryEnd = FString();


	// Main function for upload
	void UploadScreenshot(FString FullFilePath);

	// This is a function to add simple text fields to response
	FString AddData(FString Name, FString Value);

	// Callback to process response from the server
	void ProcessResponse(FString ResponseContent);

	// This will be called after screenshot has been taken
	void ScreenshotCallback();
	
};

Теперь нужно нашкодить в cpp файл, собственно, вот,

MyGameModeBase.cpp:

#include "MyGameModeBase.h"

#include "ImageUtils.h"
#include "HighResScreenshot.h"
#include "LevelEditor.h"
#include "LevelEditor/Public/SLevelViewport.h"

TArray<uint8> FStringToUint8(const FString& InString)
{
	TArray<uint8> OutBytes;

	// Handle empty strings
	if (InString.Len() > 0)
	{
		FTCHARToUTF8 Converted(*InString); // Convert to UTF8
		OutBytes.Append(reinterpret_cast<const uint8*>(Converted.Get()), Converted.Length());
	}

	return OutBytes;
}

FString AMyGameModeBase::AddData(FString Name, FString Value) {
	return FString(TEXT("\r\n"))
		+ BoundaryBegin
		+ FString(TEXT("Content-Disposition: form-data; name=\""))
		+ Name
		+ FString(TEXT("\"\r\n\r\n"))
		+ Value;
}

void AMyGameModeBase::TakeScreenshot()
{
	FHighResScreenshotConfig& HighResScreenshotConfig = GetHighResScreenshotConfig();
	HighResScreenshotConfig.SetResolution(1920, 1080);
	HighResScreenshotConfig.SetFilename(ScreenshotFileName);

	HighResScreenshotConfig.SetMaskEnabled(false);
	HighResScreenshotConfig.SetHDRCapture(false);

	FLevelEditorModule& LevelEditor = FModuleManager::GetModuleChecked<FLevelEditorModule>("LevelEditor");
	SLevelViewport* LevelViewport = LevelEditor.GetFirstActiveLevelViewport().Get();


	FScreenshotRequest::OnScreenshotRequestProcessed().AddUObject(this, &AMyGameModeBase::ScreenshotCallback);

	LevelViewport->GetActiveViewport()->TakeHighResScreenShot();

}

void AMyGameModeBase::ScreenshotCallback()
{
	FScreenshotRequest::OnScreenshotRequestProcessed().RemoveAll(this);
	UploadScreenshot(ScreenshotFileName);
};

void AMyGameModeBase::UploadScreenshot(FString FullFilePath)
{
	FString FileName = FPaths::GetCleanFilename(FullFilePath);

	FHttpModule& HttpModule = FHttpModule::Get();
	TSharedRef<IHttpRequest, ESPMode::ThreadSafe> HttpRequest = HttpModule.CreateRequest();

	// We set the api URL
	HttpRequest->SetURL("http://127.0.0.1:3000/upload");
	
	// We set verb of the request (GET/PUT/POST)
	HttpRequest->SetVerb(TEXT("POST"));


	// Create a boundary label, for the header
	BoundaryLabel = FString(TEXT("e543322540af456f9a3773049ca02529-")) + FString::FromInt(FMath::Rand());
	// boundary label for begining of every payload chunk 
	BoundaryBegin = FString(TEXT("--")) + BoundaryLabel + FString(TEXT("\r\n"));
	// boundary label for the end of payload
	BoundaryEnd = FString(TEXT("\r\n--")) + BoundaryLabel + FString(TEXT("--\r\n"));

	// Set the content-type for server to know what are we going to send
	HttpRequest->SetHeader(TEXT("Content-Type"), FString(TEXT("multipart/form-data; boundary=")) + BoundaryLabel);


	// This is binary content of the request
	TArray<uint8> CombinedContent;
	
	TArray<uint8> FileRawData;
	FFileHelper::LoadFileToArray(FileRawData, *FullFilePath);


	// First, we add the boundary for the file, which is different from text payload
	FString FileBoundaryString = FString(TEXT("\r\n"))
		+ BoundaryBegin
		+ FString(TEXT("Content-Disposition: form-data; name=\"uploadFile\"; filename=\""))
		+ FileName + "\"\r\n"
		+ "Content-Type: image/png"
		+ FString(TEXT("\r\n\r\n"));
		
	// Notice, we convert all strings into uint8 format using FStringToUint8
	CombinedContent.Append(FStringToUint8(FileBoundaryString));
	
	// Append the file data
	CombinedContent.Append(FileRawData);
	
	// Let's add couple of text values to the payload
	CombinedContent.Append(FStringToUint8(AddData("IAmAKey", "IamAValue")));
	CombinedContent.Append(FStringToUint8(AddData("IAmAnotherKey", "IAmAnotherValue")));

	// Finally, add a boundary at the end of the payload
	CombinedContent.Append(FStringToUint8(BoundaryEnd));

	// Set the request content
	HttpRequest->SetContent(CombinedContent);


	// Hook a lambda(anonymous function) to when we receive a response
	HttpRequest->OnProcessRequestComplete().BindLambda(
		[this](
			FHttpRequestPtr pRequest,
			FHttpResponsePtr pResponse,
			bool connectedSuccessfully) mutable {
				UE_LOG(LogTemp, Error, TEXT("Connection."));

				if (connectedSuccessfully) {
					ProcessResponse(pResponse->GetContentAsString());
				}
				else {
					switch (pRequest->GetStatus()) {
					case EHttpRequestStatus::Failed_ConnectionError:
						UE_LOG(LogTemp, Error, TEXT("Connection failed."));
					default:
						UE_LOG(LogTemp, Error, TEXT("Request failed."));
					}
				}
		});

	// Send the request 
	HttpRequest->ProcessRequest();

}

void AMyGameModeBase::ProcessResponse(FString ResponseContent)
{
	// Here you can process the response body
	UE_LOG(LogTemp, Error, TEXT("Response: %s"), *ResponseContent);
}

Далее, в файле build.cs вашего проекта, в разделе PublicDependencyModuleNames, добавьте несколько зависимостей, вот как это должно выглядеть:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HTTP" });

Скомпилируйте проект (Ctrl+b). Также можно сразу запустить редактор с помощью Ctrl+F5, если он не открыт (спасибо, Кэп!).

В редакторе, перейдите в папку с вашим С++ классом:

путь к созданному С++ классу
путь к созданному С++ классу

Нажмите правой кнопкой мыши на иконке класса, и выберите Create blueprint class from MyGameModeBase, я свой назвал BP_MyGameModeBase.

Откройте созданный blueprint, добавьте горячую клавишу на любую кнопку, я добавил на Home. Не забудьте в BeginPlay добавить ноду Enable Input, чтобы событие заработало. Вот как это выглядит в готовом виде:

Готовый BP_MyGameMode
Готовый BP_MyGameMode

Скомпилируйте и сохраните ваш GameMode.

Перейдя в настройки проекта (Edit->Project settings -> Maps&Modes -> Default Modes), укажите только что созданный BP в свойстве Default GameMode.

Настройки проекта
Настройки проекта

Запустите проект, и нажмите заветную горячую клавишу, указанную в BP. В логе можно будет увидеть такое (категория Error в логе просто для наглядности):

LogTemp: Warning: Screenshot name:
LogRenderer: Reallocating scene render targets to support 1940x1080 Format 10 NumSamples 1 (Frame:366).
LogClient: High resolution screenshot saved as D:/shots/screenshot.png
LogTemp: Error: Connection.
LogTemp: Error: Response: {"message":"OK"}

 

Если вы не остановили сервер по завершению прошлой части, то ваш скриншот будет в папке сервера /public, имя файла: screenshot.png.

Лог сервера
Лог сервера

В логе сервера будет выведена строка «IAmAValue», это тестовое значение, которое было добавлено для полноты кода (в cpp файле функция AddData). На сервере ее можно получить с помощью console.log(req.body.IAmAKey);

Вот, собственно, и все.

Большое спасибо за внимание, надеюсь, что статья оказалась полезна.

Для тех, кто не знаком с запросами, ну и для всех кто не очень понял, что я там на лапшекодил в cpp файле, пожалуйте под кат, будет не очень интересно, но надеюсь познавательно.

Сперва, посмотрим на функцию TakeScreenshot. Я использовал HighResScreenshot, она отличается от RequestScreenshot(которую можно напрямую считать из памяти, и не делать лишних телодвижений по записи и чтению файлов). Если вы знаете как обойти эту проблему в случае HighResShot без изменения кода движка, буду очень признателен за подсказку.

Для начала, нужно сконфигурировать наш скриншот через структуру конфигурации FHighResScreenshotConfig, которую можно получить с помощью функции GetHighResScreenshotConfig().

Укажем имя файла и разрешение картинки (SetFilename и SetResolution соответственно).

Так как скриншот занимает время, нам нужно подписаться на событие, которое будет вызвано после завершения операции:

FScreenshotRequest::OnScreenshotRequestProcessed().AddUObject(this, &AMyGameModeBase::ScreenshotCallback);

указав адрес нужной функции для обработки: (&AMyGameModeBase::ScreenshotCallback).

 

Теперь разберем функцию запроса к серверу UploadScreenshot(ScreenshotFileName)

Во-первых, посмотрим на структуру запроса. Не вдаваясь в дебри, у него есть заголовки и payload, в котором расположены нужные нам свойства (да, например, наша картинка).

Он разбит на части разделителем (код разделителя один на весь запрос, потому что сервер может получать запросы по частям, и для того чтобы правильно собрать его на сервере, он и нужен). Он указан в заголовке запроса, и в payload для каждого свойства, также разделитель должен быть уникальным между запросами, чтобы сервер не собрал трансформер из двух запросов.

Вот пример картинки:

 -----------------------------36046369012372559869891778320 
Content-Disposition: form-data; name="uploadFile"; filename="tayron_family.jpg" 
Content-Type: image/jpeg

…b°mÒđ.6£á'Ž?V&H(*LXv¬«}Ýxä@Ì¡9Æ¢¯Ñ¿Z9^Jki

-----------------------------36046369012372559869891778320--

этот набор непонятных символов и есть наша картинка (вернее часть ее, как она выглядит в текстовом редакторе).

Вот так выглядит текстовое свойство:

--e543322540af456f9a3773049ca02529-9903
Content-Disposition: form-data; name="IAmTheKey"
IAmTheValue

--e543322540af456f9a3773049ca02529-9903--

Которое будет трансформировано на сервере в JSON объект { "IAmTheKey": "IAmTheValue"}

Простите меня, гуру http, я не владею терминами, и наверное нагородил чепухи в каких-то местах.

Создать запрос в UE можно с помощью вызова функции HttpModule.CreateRequest(), она вернет объект, к которому мы будем в дальнейшем добавлять свойства.

Укажем заголовок запроса:

HttpRequest->SetHeader(TEXT("Content-Type"), FString(TEXT("multipart/form-data; boundary=")) + BoundaryLabel);

Здесь мы определяем структуру запроса (Content-Type) вида multipart/form-data. Мы также указываем разделитель, который будет добавлен в payload ниже.

Считываем картинку с диска в массив:

FFileHelper::LoadFileToArray(FileRawData, *FullFilePath);

Сначала, в переменную CombinedContent, которая будет хранить payload запроса в виде массива (TArray<uint8>), нужно добавить разделитель, но его нужно конвертировать в этот самый uint8, для чего используем функцию FStringToUint8(FileBoundaryString)

Если вы заметили, в секции с картинкой, у нас указан Content-Type: image/jpeg. Если не указать, сервер не поймет, что мы ему впариваем прислали, и не сможет конвертировать набор байтов в картинку.

С помощью вот этого HttpRequest->OnProcessRequestComplete().BindLambda[this](...) создаем лямбду (это такое красивое слово для безымянной функции), но так как она - лямбда, она не знает откуда была вызвана, т.е. в теле функции мы не можем просто так взять, и вызвать родительский объект с помощью this, для этого нам понадобится... да, он самый this, только в аргументах функции, вон он, в квадратных скобочках после BindLambda, на заморском это называется capturing. Теперь можно свободно обращаться к родительскому объекту из тела функ лямбды:

if (connectedSuccessfully) 
{
  ProcessResponse(pResponse->GetContentAsString());
}

И теперь мы можем отправить нашу картинку на сервер строкой: HttpRequest->ProcessRequest();

Вот и все на сегодня, спасибо, что вы прочитали это довольно сумбурное объяснение.

 

Комментарии (1)


  1. igrovestnik
    06.02.2023 10:45

    Спасибо за полезную статью!