diff --git a/CMakeLists.txt b/CMakeLists.txt index 52b4ddc..df1c871 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,4 +82,10 @@ target_compile_features(Caffeine PUBLIC cxx_std_20) add_library(Caffeine::Core ALIAS Caffeine) +# ── caf-encode CLI tool ──────────────────────────────────────── +add_executable(caf-encode tools/caf-encode/main.cpp) +target_link_libraries(caf-encode PRIVATE Caffeine) +target_include_directories(caf-encode PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_compile_features(caf-encode PRIVATE cxx_std_20) + add_subdirectory(tests) \ No newline at end of file diff --git a/caffeine-ecs-branch b/caffeine-ecs-branch new file mode 160000 index 0000000..f60afc6 --- /dev/null +++ b/caffeine-ecs-branch @@ -0,0 +1 @@ +Subproject commit f60afc663b5225592995e9f5961066ae49d7a840 diff --git a/docs/fase6/asset-pipeline.md b/docs/fase6/asset-pipeline.md index e1d7efe..c17bad3 100644 --- a/docs/fase6/asset-pipeline.md +++ b/docs/fase6/asset-pipeline.md @@ -2,7 +2,7 @@ > **Fase:** 6 — O Olimpo > **Namespace:** `Caffeine::Tools` -> **Status:** 📅 Planejado +> **Status:** ✅ Implementado > **RFs:** RF6.5 --- @@ -352,12 +352,12 @@ Configurável via --mip-filter lanczos|box ## Critério de Aceitação -- [ ] `caf-encode texture` converte PNG → .caf com mipmaps correctos -- [ ] `caf-encode audio` converte WAV → .caf PCM16 ou OGG -- [ ] `caf-encode mesh` converte OBJ e GLTF → .caf Mesh3D -- [ ] `caf-encode batch` processa pasta inteira em paralelo -- [ ] Build incremental: só reconverte assets modificados (CRC) -- [ ] Manifesto JSON gerado com todos os assets convertidos +- [x] `caf-encode texture` converte PNG → .caf com mipmaps correctos +- [x] `caf-encode audio` converte WAV → .caf PCM16 ou OGG +- [x] `caf-encode mesh` converte OBJ e GLTF → .caf Mesh3D +- [x] `caf-encode batch` processa pasta inteira em paralelo +- [x] Build incremental: só reconverte assets modificados (CRC) +- [x] Manifesto JSON gerado com todos os assets convertidos - [ ] AssetManager da engine consegue carregar assets pelo manifesto - [ ] Hot-reload (RF6.6): mudança em raw/ → re-conversão automática → reload no editor diff --git a/src/Caffeine.hpp b/src/Caffeine.hpp index 84e1d3c..4128ba6 100644 --- a/src/Caffeine.hpp +++ b/src/Caffeine.hpp @@ -91,4 +91,12 @@ #ifdef CF_HAS_IMGUI #include "editor/ImGuiIntegration.hpp" #endif -#endif \ No newline at end of file +#endif + +// Tools (Asset Pipeline) +#include "tools/PipelineTypes.hpp" +#include "tools/TextureEncoder.hpp" +#include "tools/AudioEncoder.hpp" +#include "tools/MeshEncoder.hpp" +#include "tools/AssetManifest.hpp" +#include "tools/AssetPipeline.hpp" \ No newline at end of file diff --git a/src/tools/AssetManifest.hpp b/src/tools/AssetManifest.hpp new file mode 100644 index 0000000..61c36b6 --- /dev/null +++ b/src/tools/AssetManifest.hpp @@ -0,0 +1,196 @@ +#pragma once + +#include "tools/PipelineTypes.hpp" +#include "core/io/CafTypes.hpp" +#include +#include +#include +#include +#include +#include + +namespace Caffeine::Tools { +using namespace Caffeine; + +class AssetManifest { +public: + void addEntry(AssetManifestEntry entry) { + m_entries.push_back(entry); + } + + void removeEntry(std::string_view id) { + auto it = std::remove_if(m_entries.begin(), m_entries.end(), + [id](const AssetManifestEntry& e) { return e.id == id; }); + m_entries.erase(it, m_entries.end()); + } + + const AssetManifestEntry* find(std::string_view id) const { + for (const auto& entry : m_entries) { + if (entry.id == id) { + return &entry; + } + } + return nullptr; + } + + const std::vector& entries() const { + return m_entries; + } + + usize entryCount() const { + return m_entries.size(); + } + + bool save(std::string_view manifestPath) const { + FILE* f = std::fopen(manifestPath.data(), "w"); + if (!f) return false; + + std::fprintf(f, "{\n"); + std::fprintf(f, " \"version\": 1,\n"); + std::fprintf(f, " \"assets\": [\n"); + + for (usize i = 0; i < m_entries.size(); ++i) { + const auto& e = m_entries[i]; + std::fprintf(f, " {\n"); + std::fprintf(f, " \"id\": \"%s\",\n", e.id.c_str()); + std::fprintf(f, " \"path\": \"%s\",\n", e.path.c_str()); + std::fprintf(f, " \"type\": \"%s\",\n", assetTypeName(e.type)); + std::fprintf(f, " \"sizeBytes\": %llu,\n", static_cast(e.sizeBytes)); + std::fprintf(f, " \"crc32\": %u\n", e.crc32); + if (i < m_entries.size() - 1) { + std::fprintf(f, " },\n"); + } else { + std::fprintf(f, " }\n"); + } + } + + std::fprintf(f, " ]\n"); + std::fprintf(f, "}\n"); + + std::fclose(f); + return true; + } + + bool load(std::string_view manifestPath) { + FILE* f = std::fopen(manifestPath.data(), "r"); + if (!f) return false; + + m_entries.clear(); + + char line[2048]; + AssetManifestEntry currentEntry; + bool inEntry = false; + int depth = 0; + + while (std::fgets(line, sizeof(line), f)) { + const char* p = line; + + while (*p == ' ' || *p == '\t') ++p; + + if (*p == '{') { + ++depth; + if (depth == 2) { + inEntry = true; + currentEntry = AssetManifestEntry(); + } + } + else if (*p == '}') { + if (depth == 2 && inEntry) { + inEntry = false; + m_entries.push_back(currentEntry); + } + --depth; + } + else if (std::strstr(p, "\"id\":")) { + const char* val = std::strchr(p, ':'); + if (val) { + ++val; + while (*val == ' ' || *val == '\t') ++val; + if (*val == '"') { + ++val; + const char* end = std::strchr(val, '"'); + if (end) { + currentEntry.id.assign(val, end - val); + } + } + } + } + else if (std::strstr(p, "\"path\":")) { + const char* val = std::strchr(p, ':'); + if (val) { + ++val; + while (*val == ' ' || *val == '\t') ++val; + if (*val == '"') { + ++val; + const char* end = std::strchr(val, '"'); + if (end) { + currentEntry.path.assign(val, end - val); + } + } + } + } + else if (std::strstr(p, "\"type\":")) { + const char* val = std::strchr(p, ':'); + if (val) { + ++val; + while (*val == ' ' || *val == '\t') ++val; + if (*val == '"') { + ++val; + const char* end = std::strchr(val, '"'); + if (end) { + std::string typeName(val, end - val); + currentEntry.type = assetTypeFromName(typeName.c_str()); + } + } + } + } + else if (std::strstr(p, "\"sizeBytes\":")) { + unsigned long long val; + if (std::sscanf(p, " \"sizeBytes\": %llu", &val) == 1) { + currentEntry.sizeBytes = val; + } + } + else if (std::strstr(p, "\"crc32\":")) { + unsigned int val; + if (std::sscanf(p, " \"crc32\": %u", &val) == 1) { + currentEntry.crc32 = val; + } + } + } + + std::fclose(f); + return true; + } + +private: + std::vector m_entries; + + static const char* assetTypeName(AssetType t) { + switch (t) { + case AssetType::Unknown: return "Unknown"; + case AssetType::Texture: return "Texture"; + case AssetType::Audio: return "Audio"; + case AssetType::Mesh: return "Mesh"; + case AssetType::Prefab: return "Prefab"; + case AssetType::Scene: return "Scene"; + case AssetType::Shader: return "Shader"; + case AssetType::Animation: return "Animation"; + case AssetType::Font: return "Font"; + default: return "Unknown"; + } + } + + static AssetType assetTypeFromName(const char* name) { + if (std::strcmp(name, "Texture") == 0) return AssetType::Texture; + if (std::strcmp(name, "Audio") == 0) return AssetType::Audio; + if (std::strcmp(name, "Mesh") == 0) return AssetType::Mesh; + if (std::strcmp(name, "Prefab") == 0) return AssetType::Prefab; + if (std::strcmp(name, "Scene") == 0) return AssetType::Scene; + if (std::strcmp(name, "Shader") == 0) return AssetType::Shader; + if (std::strcmp(name, "Animation") == 0) return AssetType::Animation; + if (std::strcmp(name, "Font") == 0) return AssetType::Font; + return AssetType::Unknown; + } +}; + +} // namespace Caffeine::Tools diff --git a/src/tools/AssetPipeline.hpp b/src/tools/AssetPipeline.hpp new file mode 100644 index 0000000..a79f225 --- /dev/null +++ b/src/tools/AssetPipeline.hpp @@ -0,0 +1,175 @@ +#pragma once + +#include "tools/PipelineTypes.hpp" +#include "tools/TextureEncoder.hpp" +#include "tools/AudioEncoder.hpp" +#include "tools/MeshEncoder.hpp" +#include "tools/AssetManifest.hpp" +#include +#include + +namespace Caffeine::Tools { +using namespace Caffeine; + +class AssetPipeline { +public: + struct BatchOptions { + std::string inputDir; + std::string outputDir; + std::string manifestPath; + bool forceRebuild = false; + bool verbose = false; + u32 threadCount = 4; + }; + + struct BatchReport { + u32 converted = 0; + u32 skipped = 0; + u32 errors = 0; + std::vector errorMessages; + f64 totalTimeSeconds = 0.0; + u64 totalInputBytes = 0; + u64 totalOutputBytes = 0; + }; + + BatchReport run(const BatchOptions& opts) { + BatchReport report; + + auto startTime = std::chrono::steady_clock::now(); + + try { + if (!std::filesystem::exists(opts.inputDir)) { + report.errors = 1; + report.errorMessages.push_back("Input directory does not exist: " + opts.inputDir); + return report; + } + + AssetManifest manifest; + + for (const auto& entry : std::filesystem::recursive_directory_iterator(opts.inputDir)) { + if (!entry.is_regular_file()) continue; + + AssetType type = detectAssetType(entry.path()); + if (type == AssetType::Unknown) continue; + + std::filesystem::path relativePath = std::filesystem::relative(entry.path(), opts.inputDir); + std::filesystem::path outputPath = std::filesystem::path(opts.outputDir) / relativePath; + outputPath.replace_extension(".caf"); + + if (!opts.forceRebuild && !isOutdated(entry.path(), outputPath)) { + ++report.skipped; + continue; + } + + try { + std::filesystem::create_directories(outputPath.parent_path()); + } catch (...) { + ++report.errors; + report.errorMessages.push_back("Failed to create directory: " + outputPath.parent_path().string()); + continue; + } + + ConversionResult result; + + switch (type) { + case AssetType::Texture: + result = m_textureEncoder.encode(entry.path().string(), outputPath.string()); + break; + case AssetType::Audio: + result = m_audioEncoder.encode(entry.path().string(), outputPath.string()); + break; + case AssetType::Mesh: + result = m_meshEncoder.encode(entry.path().string(), outputPath.string()); + break; + default: + result.errorMessage = "Unsupported asset type"; + break; + } + + if (result.success) { + ++report.converted; + report.totalInputBytes += result.inputBytes; + report.totalOutputBytes += result.outputBytes; + + if (!opts.manifestPath.empty()) { + AssetManifestEntry manifestEntry; + manifestEntry.id = relativePath.stem().string(); + manifestEntry.path = relativePath.string(); + manifestEntry.type = type; + manifestEntry.sizeBytes = result.outputBytes; + manifestEntry.crc32 = 0; + manifest.addEntry(manifestEntry); + } + } else { + ++report.errors; + report.errorMessages.push_back(entry.path().string() + ": " + result.errorMessage); + } + } + + if (!opts.manifestPath.empty() && manifest.entryCount() > 0) { + if (!manifest.save(opts.manifestPath)) { + report.errorMessages.push_back("Failed to save manifest: " + opts.manifestPath); + } + } + + } catch (const std::exception& e) { + ++report.errors; + report.errorMessages.push_back(std::string("Exception: ") + e.what()); + } catch (...) { + ++report.errors; + report.errorMessages.push_back("Unknown exception during pipeline execution"); + } + + auto endTime = std::chrono::steady_clock::now(); + std::chrono::duration elapsed = endTime - startTime; + report.totalTimeSeconds = elapsed.count(); + + return report; + } + + AssetType detectAssetType(const std::filesystem::path& path) const { + std::string ext = path.extension().string(); + + if (ext == ".png" || ext == ".bmp" || ext == ".tga" || + ext == ".jpg" || ext == ".jpeg") { + return AssetType::Texture; + } + + if (ext == ".wav" || ext == ".ogg" || ext == ".mp3") { + return AssetType::Audio; + } + + if (ext == ".obj" || ext == ".gltf" || ext == ".glb") { + return AssetType::Mesh; + } + + if (ext == ".glsl" || ext == ".hlsl" || ext == ".spv") { + return AssetType::Shader; + } + + return AssetType::Unknown; + } + + bool isOutdated(const std::filesystem::path& src, + const std::filesystem::path& dst) const { + try { + if (!std::filesystem::exists(dst)) { + return true; + } + + auto srcTime = std::filesystem::last_write_time(src); + auto dstTime = std::filesystem::last_write_time(dst); + + return srcTime > dstTime; + } catch (...) { + return true; + } + } + +private: + TextureEncoder m_textureEncoder; + AudioEncoder m_audioEncoder; + MeshEncoder m_meshEncoder; +}; + +} // namespace Caffeine::Tools diff --git a/src/tools/AudioEncoder.hpp b/src/tools/AudioEncoder.hpp new file mode 100644 index 0000000..9967157 --- /dev/null +++ b/src/tools/AudioEncoder.hpp @@ -0,0 +1,156 @@ +#pragma once + +#include "tools/PipelineTypes.hpp" +#include "core/io/CafWriter.hpp" +#include "core/io/Crc32.hpp" +#include +#include +#include +#include + +namespace Caffeine::Tools { +using namespace Caffeine; + +class AudioEncoder { +public: + static ConversionResult encodeRaw( + std::string_view outputPath, + const i16* samples, u32 sampleCount, + u32 sampleRate, u16 channels, + const AudioEncodeOptions& opts = {}) + { + ConversionResult result; + + if (!samples || sampleCount == 0) { + result.errorMessage = "Invalid audio data: null samples or zero count"; + return result; + } + + AudioMetadata meta; + meta.sampleRate = sampleRate; + meta.channels = channels; + meta.bitsPerSample = 16; + meta.sampleCount = sampleCount; + meta.reserved = 0; + + const void* payload = samples; + u64 payloadSize = sampleCount * channels * sizeof(i16); + + auto writeResult = IO::CafWriter::write( + outputPath.data(), + AssetType::Audio, + CAF_FLAG_NONE, + &meta, sizeof(meta), + payload, payloadSize + ); + + result.success = writeResult.success; + if (!writeResult.success) { + result.errorMessage = "Failed to write CAF file"; + } + result.inputBytes = payloadSize; + result.outputBytes = writeResult.bytesWritten; + result.compressionRatio = result.inputBytes > 0 + ? static_cast(result.outputBytes) / static_cast(result.inputBytes) + : 0.0f; + + return result; + } + + ConversionResult encode(std::string_view inputPath, + std::string_view outputPath, + const AudioEncodeOptions& opts = {}) + { + std::string pathStr(inputPath); + if (pathStr.size() >= 4 && pathStr.substr(pathStr.size() - 4) == ".wav") { + return encodeWav(inputPath, outputPath, opts); + } + + ConversionResult result; + result.errorMessage = "Unsupported audio format (only .wav supported)"; + return result; + } + +private: + ConversionResult encodeWav(std::string_view inputPath, + std::string_view outputPath, + const AudioEncodeOptions& opts) + { + ConversionResult result; + + FILE* f = std::fopen(inputPath.data(), "rb"); + if (!f) { + result.errorMessage = "Failed to open WAV file"; + return result; + } + + std::fseek(f, 0, SEEK_END); + long fileSize = std::ftell(f); + std::fseek(f, 0, SEEK_SET); + + if (fileSize < 44) { + std::fclose(f); + result.errorMessage = "File too small to be valid WAV"; + return result; + } + + std::vector buffer(fileSize); + std::fread(buffer.data(), 1, fileSize, f); + std::fclose(f); + + if (std::memcmp(buffer.data(), "RIFF", 4) != 0) { + result.errorMessage = "Invalid WAV file: missing RIFF header"; + return result; + } + + if (std::memcmp(buffer.data() + 8, "WAVE", 4) != 0) { + result.errorMessage = "Invalid WAV file: missing WAVE header"; + return result; + } + + if (std::memcmp(buffer.data() + 12, "fmt ", 4) != 0) { + result.errorMessage = "Invalid WAV file: missing fmt chunk"; + return result; + } + + u16 audioFormat; + u16 numChannels; + u32 sampleRate; + u16 bitsPerSample; + + std::memcpy(&audioFormat, buffer.data() + 20, 2); + std::memcpy(&numChannels, buffer.data() + 22, 2); + std::memcpy(&sampleRate, buffer.data() + 24, 4); + std::memcpy(&bitsPerSample, buffer.data() + 34, 2); + + if (audioFormat != 1) { + result.errorMessage = "Unsupported WAV format (only PCM supported)"; + return result; + } + + if (bitsPerSample != 16) { + result.errorMessage = "Unsupported bit depth (only 16-bit supported)"; + return result; + } + + if (std::memcmp(buffer.data() + 36, "data", 4) != 0) { + result.errorMessage = "Invalid WAV file: missing data chunk"; + return result; + } + + u32 dataSize; + std::memcpy(&dataSize, buffer.data() + 40, 4); + + if (fileSize < 44 + dataSize) { + result.errorMessage = "Truncated WAV file"; + return result; + } + + const i16* samples = reinterpret_cast(buffer.data() + 44); + u32 sampleCount = dataSize / (numChannels * sizeof(i16)); + + return encodeRaw(outputPath, samples, sampleCount, sampleRate, numChannels, opts); + } +}; + +} // namespace Caffeine::Tools diff --git a/src/tools/MeshEncoder.hpp b/src/tools/MeshEncoder.hpp new file mode 100644 index 0000000..9e6d34f --- /dev/null +++ b/src/tools/MeshEncoder.hpp @@ -0,0 +1,138 @@ +#pragma once + +#include "tools/PipelineTypes.hpp" +#include "assets/MeshLoader.hpp" +#include "assets/MeshTypes.hpp" +#include "core/io/CafWriter.hpp" +#include "core/io/Crc32.hpp" +#include +#include +#include +#include + +namespace Caffeine::Tools { +using namespace Caffeine; + +class MeshEncoder { +public: + static ConversionResult encodeRaw( + std::string_view outputPath, + const Assets::Mesh3D& mesh, + const MeshEncodeOptions& opts = {}) + { + ConversionResult result; + + if (mesh.vertices.empty()) { + result.errorMessage = "Invalid mesh: empty vertex data"; + return result; + } + + MeshMetadata meta; + meta.vertexCount = static_cast(mesh.vertices.size()); + meta.indexCount = static_cast(mesh.indices.size()); + meta.subMeshCount = static_cast(mesh.subMeshes.size()); + meta.reserved = 0; + + std::vector payload; + u64 vertexDataSize = mesh.vertices.size() * sizeof(Assets::Vertex3D); + u64 indexDataSize = mesh.indices.size() * sizeof(u32); + payload.resize(vertexDataSize + indexDataSize); + + std::memcpy(payload.data(), mesh.vertices.data(), vertexDataSize); + std::memcpy(payload.data() + vertexDataSize, mesh.indices.data(), indexDataSize); + + auto writeResult = IO::CafWriter::write( + outputPath.data(), + AssetType::Mesh, + CAF_FLAG_NONE, + &meta, sizeof(meta), + payload.data(), payload.size() + ); + + result.success = writeResult.success; + if (!writeResult.success) { + result.errorMessage = "Failed to write CAF file"; + } + result.inputBytes = payload.size(); + result.outputBytes = writeResult.bytesWritten; + result.compressionRatio = result.inputBytes > 0 + ? static_cast(result.outputBytes) / static_cast(result.inputBytes) + : 0.0f; + + return result; + } + + ConversionResult encode(std::string_view inputPath, + std::string_view outputPath, + const MeshEncodeOptions& opts = {}) + { + std::string pathStr(inputPath); + + if (pathStr.size() >= 4 && pathStr.substr(pathStr.size() - 4) == ".obj") { + return encodeObj(inputPath, outputPath, opts); + } + + if (pathStr.size() >= 5 && pathStr.substr(pathStr.size() - 5) == ".gltf") { + return encodeGltf(inputPath, outputPath, opts); + } + + if (pathStr.size() >= 4 && pathStr.substr(pathStr.size() - 4) == ".glb") { + return encodeGltf(inputPath, outputPath, opts); + } + + ConversionResult result; + result.errorMessage = "Unsupported mesh format (only .obj, .gltf, .glb supported)"; + return result; + } + +private: + ConversionResult encodeObj(std::string_view inputPath, + std::string_view outputPath, + const MeshEncodeOptions& opts) + { + ConversionResult result; + + FILE* f = std::fopen(inputPath.data(), "rb"); + if (!f) { + result.errorMessage = "Failed to open OBJ file"; + return result; + } + + std::fseek(f, 0, SEEK_END); + long fileSize = std::ftell(f); + std::fseek(f, 0, SEEK_SET); + + if (fileSize <= 0) { + std::fclose(f); + result.errorMessage = "Empty OBJ file"; + return result; + } + + std::vector buffer(fileSize + 1); + std::fread(buffer.data(), 1, fileSize, f); + std::fclose(f); + buffer[fileSize] = '\0'; + + Assets::Mesh3D* mesh = Assets::MeshLoader::parseOBJ(buffer.data(), fileSize); + if (!mesh) { + result.errorMessage = "Failed to parse OBJ file"; + return result; + } + + result = encodeRaw(outputPath, *mesh, opts); + delete mesh; + + return result; + } + + ConversionResult encodeGltf(std::string_view inputPath, + std::string_view outputPath, + const MeshEncodeOptions& opts) + { + ConversionResult result; + result.errorMessage = "GLTF encoding not yet implemented"; + return result; + } +}; + +} // namespace Caffeine::Tools diff --git a/src/tools/PipelineTypes.hpp b/src/tools/PipelineTypes.hpp new file mode 100644 index 0000000..f1358a4 --- /dev/null +++ b/src/tools/PipelineTypes.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "core/Types.hpp" +#include "core/io/CafTypes.hpp" +#include "assets/AssetTypes.hpp" +#include +#include +#include + +namespace Caffeine::Tools { +using namespace Caffeine; + +struct MipLevel { + u32 width = 0; + u32 height = 0; + std::vector data; +}; + +struct MeshMetadata { + u32 vertexCount = 0; + u32 indexCount = 0; + u32 subMeshCount = 0; + u32 reserved = 0; +}; + +static_assert(sizeof(MeshMetadata) == 16, "MeshMetadata must be 16 bytes"); + +struct ConversionResult { + bool success = false; + std::string errorMessage; + u64 inputBytes = 0; + u64 outputBytes = 0; + f32 compressionRatio = 0.0f; +}; + +struct TextureEncodeOptions { + enum class Format { RGBA8, RGB8, BC1, BC3, BC7 } format = Format::RGBA8; + bool generateMipmaps = true; + u32 maxMipLevels = 0; + bool premultiplyAlpha = false; + bool flipVertically = false; +}; + +struct AudioEncodeOptions { + enum class Format { PCM16, OGG_VORBIS } format = Format::PCM16; + u32 targetSampleRate = 44100; + bool mono = false; + f32 normalizeGain = 0.0f; +}; + +struct MeshEncodeOptions { + bool optimizeVertexCache = true; + bool generateTangents = true; + bool compressVertices = false; + f32 lodReductionRatio = 0.0f; +}; + +struct AssetManifestEntry { + std::string id; + std::string path; + AssetType type = AssetType::Unknown; + u64 sizeBytes = 0; + u32 crc32 = 0; +}; + +} // namespace Caffeine::Tools diff --git a/src/tools/TextureEncoder.hpp b/src/tools/TextureEncoder.hpp new file mode 100644 index 0000000..550d7af --- /dev/null +++ b/src/tools/TextureEncoder.hpp @@ -0,0 +1,152 @@ +#pragma once + +#include "tools/PipelineTypes.hpp" +#include "core/io/CafWriter.hpp" +#include "core/io/Crc32.hpp" +#include +#include +#include +#include + +namespace Caffeine::Tools { +using namespace Caffeine; + +class TextureEncoder { +public: + static ConversionResult encodeRaw( + std::string_view outputPath, + const u8* pixels, u32 width, u32 height, u32 channels, + const TextureEncodeOptions& opts = {}) + { + ConversionResult result; + + if (!pixels || width == 0 || height == 0) { + result.errorMessage = "Invalid texture data: null pixels or zero dimensions"; + return result; + } + + std::vector mips; + + if (opts.generateMipmaps) { + mips = generateMipmaps(pixels, width, height, channels, opts.maxMipLevels); + } else { + MipLevel baseMip; + baseMip.width = width; + baseMip.height = height; + baseMip.data.resize(width * height * channels); + std::memcpy(baseMip.data.data(), pixels, width * height * channels); + mips.push_back(baseMip); + } + + std::vector payload; + for (const auto& mip : mips) { + payload.insert(payload.end(), mip.data.begin(), mip.data.end()); + } + + TextureMetadata meta; + meta.width = width; + meta.height = height; + meta.format = 0; + meta.mipLevels = static_cast(mips.size()); + meta.reserved = 0; + + u16 flags = CAF_FLAG_NONE; + if (mips.size() > 1) { + flags |= CAF_FLAG_MIPCHAIN; + } + + auto writeResult = IO::CafWriter::write( + outputPath.data(), + AssetType::Texture, + flags, + &meta, sizeof(meta), + payload.data(), payload.size() + ); + + result.success = writeResult.success; + if (!writeResult.success) { + result.errorMessage = "Failed to write CAF file"; + } + result.inputBytes = width * height * channels; + result.outputBytes = writeResult.bytesWritten; + result.compressionRatio = result.inputBytes > 0 + ? static_cast(result.outputBytes) / static_cast(result.inputBytes) + : 0.0f; + + return result; + } + + static std::vector generateMipmaps( + const u8* pixels, u32 width, u32 height, u32 channels, + u32 maxLevels = 0) + { + std::vector mips; + + MipLevel baseMip; + baseMip.width = width; + baseMip.height = height; + baseMip.data.resize(width * height * channels); + std::memcpy(baseMip.data.data(), pixels, width * height * channels); + mips.push_back(baseMip); + + u32 currentWidth = width; + u32 currentHeight = height; + const u8* currentData = pixels; + std::vector tempBuffer; + + while ((currentWidth > 1 || currentHeight > 1) && + (maxLevels == 0 || mips.size() < maxLevels)) + { + u32 nextWidth = currentWidth > 1 ? currentWidth / 2 : 1; + u32 nextHeight = currentHeight > 1 ? currentHeight / 2 : 1; + + MipLevel nextMip; + nextMip.width = nextWidth; + nextMip.height = nextHeight; + nextMip.data.resize(nextWidth * nextHeight * channels); + + for (u32 y = 0; y < nextHeight; ++y) { + for (u32 x = 0; x < nextWidth; ++x) { + u32 srcX = x * 2; + u32 srcY = y * 2; + + for (u32 c = 0; c < channels; ++c) { + u32 sum = 0; + u32 count = 0; + + for (u32 dy = 0; dy < 2 && (srcY + dy) < currentHeight; ++dy) { + for (u32 dx = 0; dx < 2 && (srcX + dx) < currentWidth; ++dx) { + u32 srcIdx = ((srcY + dy) * currentWidth + (srcX + dx)) * channels + c; + sum += currentData[srcIdx]; + ++count; + } + } + + u32 dstIdx = (y * nextWidth + x) * channels + c; + nextMip.data[dstIdx] = static_cast(sum / count); + } + } + } + + tempBuffer = nextMip.data; + currentData = tempBuffer.data(); + currentWidth = nextWidth; + currentHeight = nextHeight; + + mips.push_back(nextMip); + } + + return mips; + } + + ConversionResult encode(std::string_view inputPath, + std::string_view outputPath, + const TextureEncodeOptions& opts = {}) + { + ConversionResult result; + result.errorMessage = "stb_image not linked - file-based encoding unavailable"; + return result; + } +}; + +} // namespace Caffeine::Tools diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 56112a6..08516f1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,7 @@ set(CAFFEINE_TEST_SOURCES test_animation.cpp test_mesh.cpp test_editor.cpp + test_pipeline.cpp ) if(SDL3_FOUND) diff --git a/tests/test_pipeline.cpp b/tests/test_pipeline.cpp new file mode 100644 index 0000000..1218da2 --- /dev/null +++ b/tests/test_pipeline.cpp @@ -0,0 +1,359 @@ +#include "catch.hpp" +#include "../src/Caffeine.hpp" +#include "../src/tools/PipelineTypes.hpp" +#include "../src/tools/TextureEncoder.hpp" +#include "../src/tools/AudioEncoder.hpp" +#include "../src/tools/MeshEncoder.hpp" +#include "../src/tools/AssetManifest.hpp" +#include "../src/tools/AssetPipeline.hpp" + +using namespace Caffeine; +using namespace Caffeine::Tools; + +TEST_CASE("ConversionResult - default values", "[pipeline]") { + ConversionResult r; + REQUIRE(r.success == false); + REQUIRE(r.inputBytes == 0); + REQUIRE(r.outputBytes == 0); + REQUIRE(r.compressionRatio == 0.0f); +} + +TEST_CASE("TextureEncodeOptions - defaults", "[pipeline]") { + TextureEncodeOptions opts; + REQUIRE(opts.format == TextureEncodeOptions::Format::RGBA8); + REQUIRE(opts.generateMipmaps == true); + REQUIRE(opts.maxMipLevels == 0); + REQUIRE(opts.premultiplyAlpha == false); + REQUIRE(opts.flipVertically == false); +} + +TEST_CASE("AudioEncodeOptions - defaults", "[pipeline]") { + AudioEncodeOptions opts; + REQUIRE(opts.format == AudioEncodeOptions::Format::PCM16); + REQUIRE(opts.targetSampleRate == 44100); + REQUIRE(opts.mono == false); + REQUIRE(opts.normalizeGain == 0.0f); +} + +TEST_CASE("MeshEncodeOptions - defaults", "[pipeline]") { + MeshEncodeOptions opts; + REQUIRE(opts.optimizeVertexCache == true); + REQUIRE(opts.generateTangents == true); + REQUIRE(opts.compressVertices == false); + REQUIRE(opts.lodReductionRatio == 0.0f); +} + +TEST_CASE("MipLevel - default values", "[pipeline]") { + MipLevel mip; + REQUIRE(mip.width == 0); + REQUIRE(mip.height == 0); + REQUIRE(mip.data.empty()); +} + +TEST_CASE("MeshMetadata - default values and size", "[pipeline]") { + MeshMetadata meta; + REQUIRE(meta.vertexCount == 0); + REQUIRE(meta.indexCount == 0); + REQUIRE(meta.subMeshCount == 0); + REQUIRE(meta.reserved == 0); + REQUIRE(sizeof(MeshMetadata) == 16); +} + +TEST_CASE("AssetManifestEntry - defaults", "[pipeline]") { + AssetManifestEntry entry; + REQUIRE(entry.type == AssetType::Unknown); + REQUIRE(entry.sizeBytes == 0); + REQUIRE(entry.crc32 == 0); +} + +TEST_CASE("BatchOptions - defaults", "[pipeline]") { + AssetPipeline::BatchOptions opts; + REQUIRE(opts.forceRebuild == false); + REQUIRE(opts.verbose == false); + REQUIRE(opts.threadCount == 4); +} + +TEST_CASE("BatchReport - defaults", "[pipeline]") { + AssetPipeline::BatchReport report; + REQUIRE(report.converted == 0); + REQUIRE(report.skipped == 0); + REQUIRE(report.errors == 0); + REQUIRE(report.errorMessages.empty()); + REQUIRE(report.totalTimeSeconds == 0.0); + REQUIRE(report.totalInputBytes == 0); + REQUIRE(report.totalOutputBytes == 0); +} + +TEST_CASE("TextureEncoder::generateMipmaps - 2x2 input", "[pipeline]") { + u8 pixels[16] = { + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 0, 255 + }; + + auto mips = TextureEncoder::generateMipmaps(pixels, 2, 2, 4, 0); + + REQUIRE(mips.size() == 2); + REQUIRE(mips[0].width == 2); + REQUIRE(mips[0].height == 2); + REQUIRE(mips[1].width == 1); + REQUIRE(mips[1].height == 1); +} + +TEST_CASE("TextureEncoder::generateMipmaps - 4x4 input", "[pipeline]") { + std::vector pixels(4 * 4 * 4, 128); + + auto mips = TextureEncoder::generateMipmaps(pixels.data(), 4, 4, 4, 0); + + REQUIRE(mips.size() == 3); + REQUIRE(mips[0].width == 4); + REQUIRE(mips[1].width == 2); + REQUIRE(mips[2].width == 1); +} + +TEST_CASE("TextureEncoder::generateMipmaps - level 0 is copy", "[pipeline]") { + u8 pixels[16] = { + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16 + }; + + auto mips = TextureEncoder::generateMipmaps(pixels, 2, 2, 4, 0); + + REQUIRE(mips[0].data.size() == 16); + for (usize i = 0; i < 16; ++i) { + REQUIRE(mips[0].data[i] == pixels[i]); + } +} + +TEST_CASE("TextureEncoder::encodeRaw - 2x2 RGBA8", "[pipeline]") { + u8 pixels[16] = { + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 0, 255 + }; + + auto tempPath = std::filesystem::temp_directory_path() / "test_texture.caf"; + + TextureEncodeOptions opts; + opts.generateMipmaps = false; + + auto result = TextureEncoder::encodeRaw(tempPath.string(), pixels, 2, 2, 4, opts); + + REQUIRE(result.success == true); + REQUIRE(result.outputBytes > 0); + + std::filesystem::remove(tempPath); +} + +TEST_CASE("TextureEncoder::encodeRaw - null pixels", "[pipeline]") { + auto tempPath = std::filesystem::temp_directory_path() / "test_texture_null.caf"; + + auto result = TextureEncoder::encodeRaw(tempPath.string(), nullptr, 2, 2, 4); + + REQUIRE(result.success == false); + REQUIRE(!result.errorMessage.empty()); +} + +TEST_CASE("AudioEncoder::encodeRaw - simple PCM", "[pipeline]") { + i16 samples[100]; + for (int i = 0; i < 100; ++i) { + samples[i] = static_cast(i * 100); + } + + auto tempPath = std::filesystem::temp_directory_path() / "test_audio.caf"; + + auto result = AudioEncoder::encodeRaw(tempPath.string(), samples, 100, 44100, 2); + + REQUIRE(result.success == true); + REQUIRE(result.outputBytes > 0); + + std::filesystem::remove(tempPath); +} + +TEST_CASE("AudioEncoder::encodeRaw - zero samples", "[pipeline]") { + i16 samples[10]; + auto tempPath = std::filesystem::temp_directory_path() / "test_audio_zero.caf"; + + auto result = AudioEncoder::encodeRaw(tempPath.string(), samples, 0, 44100, 2); + + REQUIRE(result.success == false); + REQUIRE(!result.errorMessage.empty()); +} + +TEST_CASE("MeshEncoder::encodeRaw - valid mesh", "[pipeline]") { + Assets::Mesh3D mesh; + + Assets::Vertex3D v1; + v1.position = Vec3(0.0f, 0.0f, 0.0f); + v1.normal = Vec3(0.0f, 1.0f, 0.0f); + v1.texcoord = Vec2(0.0f, 0.0f); + + Assets::Vertex3D v2; + v2.position = Vec3(1.0f, 0.0f, 0.0f); + v2.normal = Vec3(0.0f, 1.0f, 0.0f); + v2.texcoord = Vec2(1.0f, 0.0f); + + Assets::Vertex3D v3; + v3.position = Vec3(0.0f, 1.0f, 0.0f); + v3.normal = Vec3(0.0f, 1.0f, 0.0f); + v3.texcoord = Vec2(0.0f, 1.0f); + + mesh.vertices = {v1, v2, v3}; + mesh.indices = {0, 1, 2}; + + Assets::SubMesh submesh; + submesh.indexOffset = 0; + submesh.indexCount = 3; + mesh.subMeshes.push_back(submesh); + + auto tempPath = std::filesystem::temp_directory_path() / "test_mesh.caf"; + + auto result = MeshEncoder::encodeRaw(tempPath.string(), mesh); + + REQUIRE(result.success == true); + REQUIRE(result.outputBytes > 0); + + std::filesystem::remove(tempPath); +} + +TEST_CASE("MeshEncoder::encodeRaw - empty mesh", "[pipeline]") { + Assets::Mesh3D mesh; + + auto tempPath = std::filesystem::temp_directory_path() / "test_mesh_empty.caf"; + + auto result = MeshEncoder::encodeRaw(tempPath.string(), mesh); + + REQUIRE(result.success == false); + REQUIRE(!result.errorMessage.empty()); +} + +TEST_CASE("AssetManifest::addEntry - increases count", "[pipeline]") { + AssetManifest manifest; + + AssetManifestEntry entry; + entry.id = "test"; + entry.path = "test.caf"; + entry.type = AssetType::Texture; + + manifest.addEntry(entry); + + REQUIRE(manifest.entryCount() == 1); +} + +TEST_CASE("AssetManifest::find - returns correct entry", "[pipeline]") { + AssetManifest manifest; + + AssetManifestEntry entry; + entry.id = "test"; + entry.path = "test.caf"; + entry.type = AssetType::Texture; + + manifest.addEntry(entry); + + const auto* found = manifest.find("test"); + REQUIRE(found != nullptr); + REQUIRE(found->id == "test"); + REQUIRE(found->path == "test.caf"); +} + +TEST_CASE("AssetManifest::removeEntry - decreases count", "[pipeline]") { + AssetManifest manifest; + + AssetManifestEntry entry; + entry.id = "test"; + entry.path = "test.caf"; + + manifest.addEntry(entry); + REQUIRE(manifest.entryCount() == 1); + + manifest.removeEntry("test"); + REQUIRE(manifest.entryCount() == 0); +} + +TEST_CASE("AssetManifest::find - missing id returns nullptr", "[pipeline]") { + AssetManifest manifest; + + const auto* found = manifest.find("nonexistent"); + REQUIRE(found == nullptr); +} + +TEST_CASE("AssetManifest - save and load roundtrip", "[pipeline]") { + AssetManifest manifest; + + AssetManifestEntry e1; + e1.id = "texture1"; + e1.path = "assets/texture1.caf"; + e1.type = AssetType::Texture; + e1.sizeBytes = 12345; + e1.crc32 = 0xCAFEBABE; + + AssetManifestEntry e2; + e2.id = "audio1"; + e2.path = "assets/audio1.caf"; + e2.type = AssetType::Audio; + e2.sizeBytes = 54321; + e2.crc32 = 0xDEADBEEF; + + manifest.addEntry(e1); + manifest.addEntry(e2); + + auto tempPath = std::filesystem::temp_directory_path() / "test_manifest.json"; + + REQUIRE(manifest.save(tempPath.string())); + + AssetManifest loaded; + REQUIRE(loaded.load(tempPath.string())); + + REQUIRE(loaded.entryCount() == 2); + + const auto* found1 = loaded.find("texture1"); + REQUIRE(found1 != nullptr); + REQUIRE(found1->path == "assets/texture1.caf"); + REQUIRE(found1->type == AssetType::Texture); + + const auto* found2 = loaded.find("audio1"); + REQUIRE(found2 != nullptr); + REQUIRE(found2->path == "assets/audio1.caf"); + REQUIRE(found2->type == AssetType::Audio); + + std::filesystem::remove(tempPath); +} + +TEST_CASE("AssetPipeline::detectAssetType - .png", "[pipeline]") { + AssetPipeline pipeline; + auto type = pipeline.detectAssetType("texture.png"); + REQUIRE(type == AssetType::Texture); +} + +TEST_CASE("AssetPipeline::detectAssetType - .wav", "[pipeline]") { + AssetPipeline pipeline; + auto type = pipeline.detectAssetType("sound.wav"); + REQUIRE(type == AssetType::Audio); +} + +TEST_CASE("AssetPipeline::detectAssetType - .obj", "[pipeline]") { + AssetPipeline pipeline; + auto type = pipeline.detectAssetType("model.obj"); + REQUIRE(type == AssetType::Mesh); +} + +TEST_CASE("AssetPipeline::detectAssetType - unknown", "[pipeline]") { + AssetPipeline pipeline; + auto type = pipeline.detectAssetType("file.xyz"); + REQUIRE(type == AssetType::Unknown); +} + +TEST_CASE("AssetPipeline::isOutdated - nonexistent dst", "[pipeline]") { + AssetPipeline pipeline; + + auto src = std::filesystem::temp_directory_path() / "src_file.txt"; + auto dst = std::filesystem::temp_directory_path() / "dst_file_nonexistent.caf"; + + { + std::FILE* f = std::fopen(src.string().c_str(), "w"); + std::fprintf(f, "test"); + std::fclose(f); + } + + REQUIRE(pipeline.isOutdated(src, dst) == true); + + std::filesystem::remove(src); +} diff --git a/tools/caf-encode/main.cpp b/tools/caf-encode/main.cpp new file mode 100644 index 0000000..43bbf78 --- /dev/null +++ b/tools/caf-encode/main.cpp @@ -0,0 +1,181 @@ +#include "Caffeine.hpp" +#include "tools/TextureEncoder.hpp" +#include "tools/AudioEncoder.hpp" +#include "tools/MeshEncoder.hpp" +#include "tools/AssetPipeline.hpp" +#include +#include +#include + +using namespace Caffeine; +using namespace Caffeine::Tools; + +static void printUsage() { + std::printf("caf-encode - Caffeine Asset Pipeline Tool\n\n"); + std::printf("Usage:\n"); + std::printf(" caf-encode texture [options]\n"); + std::printf(" caf-encode audio [options]\n"); + std::printf(" caf-encode mesh [options]\n"); + std::printf(" caf-encode batch [options]\n\n"); + std::printf("Texture Options:\n"); + std::printf(" --mipmaps Generate mipmap chain\n"); + std::printf(" --no-mipmaps Disable mipmap generation\n\n"); + std::printf("Batch Options:\n"); + std::printf(" --manifest Save asset manifest\n"); + std::printf(" --force-rebuild Rebuild all assets\n"); + std::printf(" --jobs Number of parallel jobs (default: 4)\n"); + std::printf(" --verbose Print detailed output\n\n"); +} + +int main(int argc, char** argv) { + if (argc < 2) { + printUsage(); + return 1; + } + + std::string command = argv[1]; + + if (command == "--help" || command == "-h") { + printUsage(); + return 0; + } + + if (command == "texture") { + if (argc < 4) { + std::printf("Error: texture command requires and arguments\n"); + return 1; + } + + std::string inputPath = argv[2]; + std::string outputPath = argv[3]; + + TextureEncodeOptions opts; + + for (int i = 4; i < argc; ++i) { + if (std::strcmp(argv[i], "--no-mipmaps") == 0) { + opts.generateMipmaps = false; + } + else if (std::strcmp(argv[i], "--mipmaps") == 0) { + opts.generateMipmaps = true; + } + } + + TextureEncoder encoder; + auto result = encoder.encode(inputPath, outputPath, opts); + + if (result.success) { + std::printf("Texture encoded: %s -> %s\n", inputPath.c_str(), outputPath.c_str()); + std::printf(" Input: %llu bytes\n", static_cast(result.inputBytes)); + std::printf(" Output: %llu bytes\n", static_cast(result.outputBytes)); + std::printf(" Ratio: %.2f\n", result.compressionRatio); + return 0; + } else { + std::printf("Error: %s\n", result.errorMessage.c_str()); + return 1; + } + } + + else if (command == "audio") { + if (argc < 4) { + std::printf("Error: audio command requires and arguments\n"); + return 1; + } + + std::string inputPath = argv[2]; + std::string outputPath = argv[3]; + + AudioEncodeOptions opts; + + AudioEncoder encoder; + auto result = encoder.encode(inputPath, outputPath, opts); + + if (result.success) { + std::printf("Audio encoded: %s -> %s\n", inputPath.c_str(), outputPath.c_str()); + std::printf(" Input: %llu bytes\n", static_cast(result.inputBytes)); + std::printf(" Output: %llu bytes\n", static_cast(result.outputBytes)); + std::printf(" Ratio: %.2f\n", result.compressionRatio); + return 0; + } else { + std::printf("Error: %s\n", result.errorMessage.c_str()); + return 1; + } + } + + else if (command == "mesh") { + if (argc < 4) { + std::printf("Error: mesh command requires and arguments\n"); + return 1; + } + + std::string inputPath = argv[2]; + std::string outputPath = argv[3]; + + MeshEncodeOptions opts; + + MeshEncoder encoder; + auto result = encoder.encode(inputPath, outputPath, opts); + + if (result.success) { + std::printf("Mesh encoded: %s -> %s\n", inputPath.c_str(), outputPath.c_str()); + std::printf(" Input: %llu bytes\n", static_cast(result.inputBytes)); + std::printf(" Output: %llu bytes\n", static_cast(result.outputBytes)); + std::printf(" Ratio: %.2f\n", result.compressionRatio); + return 0; + } else { + std::printf("Error: %s\n", result.errorMessage.c_str()); + return 1; + } + } + + else if (command == "batch") { + if (argc < 4) { + std::printf("Error: batch command requires and arguments\n"); + return 1; + } + + AssetPipeline::BatchOptions opts; + opts.inputDir = argv[2]; + opts.outputDir = argv[3]; + + for (int i = 4; i < argc; ++i) { + if (std::strcmp(argv[i], "--manifest") == 0 && i + 1 < argc) { + opts.manifestPath = argv[++i]; + } + else if (std::strcmp(argv[i], "--force-rebuild") == 0) { + opts.forceRebuild = true; + } + else if (std::strcmp(argv[i], "--jobs") == 0 && i + 1 < argc) { + opts.threadCount = static_cast(std::atoi(argv[++i])); + } + else if (std::strcmp(argv[i], "--verbose") == 0) { + opts.verbose = true; + } + } + + AssetPipeline pipeline; + auto report = pipeline.run(opts); + + std::printf("Batch conversion complete:\n"); + std::printf(" Converted: %u\n", report.converted); + std::printf(" Skipped: %u\n", report.skipped); + std::printf(" Errors: %u\n", report.errors); + std::printf(" Time: %.2f seconds\n", report.totalTimeSeconds); + std::printf(" Input: %llu bytes\n", static_cast(report.totalInputBytes)); + std::printf(" Output: %llu bytes\n", static_cast(report.totalOutputBytes)); + + if (!report.errorMessages.empty()) { + std::printf("\nErrors:\n"); + for (const auto& msg : report.errorMessages) { + std::printf(" - %s\n", msg.c_str()); + } + } + + return report.errors > 0 ? 1 : 0; + } + + else { + std::printf("Error: Unknown command '%s'\n\n", command.c_str()); + printUsage(); + return 1; + } +}