Привет, Хабражители!
Это моя проба пера, вполне возможно, будет комом, пардоньте.
Да, это перевод моего собственного туториала на форуме 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, чтобы событие заработало. Вот как это выглядит в готовом виде:
Скомпилируйте и сохраните ваш 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();
Вот и все на сегодня, спасибо, что вы прочитали это довольно сумбурное объяснение.
igrovestnik
Спасибо за полезную статью!