#include "render.hpp" #include #include #include #include #include #include #include #include "log.hpp" #include "path_.hpp" #include "process_.hpp" #include "sdl.hpp" #include "string_.hpp" using namespace anm2ed::util; namespace anm2ed { namespace { std::string ffmpeg_concat_escape(const std::string& value) { std::string escaped{}; escaped.reserve(value.size()); for (auto character : value) { if (character == '\'') escaped += "'\\''"; else escaped += character; } return escaped; } } bool animation_render(const std::filesystem::path& ffmpegPath, const std::filesystem::path& path, const std::vector& framePaths, const std::vector& frameDurations, AudioStream& audioStream, render::Type type, int fps) { if (framePaths.empty() || ffmpegPath.empty() || path.empty()) return false; (void)frameDurations; fps = std::max(fps, 1); auto pathString = path::to_utf8(path); auto ffmpegPathString = path::to_utf8(ffmpegPath); auto loggerPath = Logger::path(); auto loggerPathString = path::to_utf8(loggerPath); #if _WIN32 auto ffmpegTempPath = loggerPath.parent_path() / "ffmpeg_log.temp.txt"; auto ffmpegTempPathString = path::to_utf8(ffmpegTempPath); std::error_code ffmpegTempError; std::filesystem::remove(ffmpegTempPath, ffmpegTempError); #endif std::filesystem::path audioPath{}; std::string audioInputArguments{}; std::string audioOutputArguments{"-an"}; std::string command{}; auto temporaryDirectory = framePaths.front().parent_path(); if (temporaryDirectory.empty()) temporaryDirectory = std::filesystem::temp_directory_path(); auto audio_remove = [&]() { if (!audioPath.empty()) { std::error_code ec; std::filesystem::remove(audioPath, ec); } }; if (type != render::GIF && !audioStream.stream.empty() && audioStream.spec.freq > 0 && audioStream.spec.channels > 0) { auto tempFilenameUtf8 = std::format("anm2ed_audio_{}_{}.f32", std::hash{}(pathString), SDL_GetTicks()); audioPath = temporaryDirectory / path::from_utf8(tempFilenameUtf8); std::ofstream audioFile(audioPath, std::ios::binary); if (audioFile) { auto data = (const char*)audioStream.stream.data(); auto byteCount = audioStream.stream.size() * sizeof(float); audioFile.write(data, byteCount); audioFile.close(); auto sampleRate = std::max(audioStream.spec.freq, 1); auto channels = std::max(audioStream.spec.channels, 1); auto audioDurationFilter = std::string{}; auto frameCount = (double)std::max((int)framePaths.size(), 1); auto expectedDurationSeconds = frameCount / (double)fps; if (expectedDurationSeconds > 0.0) audioDurationFilter += std::format("apad,atrim=duration={:.9f}", expectedDurationSeconds); audioInputArguments = std::format("-f f32le -ar {0} -ac {1} -i \"{2}\"", sampleRate, channels, path::to_utf8(audioPath)); switch (type) { case render::WEBM: audioOutputArguments = "-c:a libopus -b:a 160k -shortest"; break; case render::MP4: audioOutputArguments = "-c:a aac -b:a 192k -shortest"; break; default: break; } if (!audioDurationFilter.empty()) audioOutputArguments = std::format("-af \"{}\" {}", audioDurationFilter, audioOutputArguments); } else { logger.warning("Failed to open temporary audio file; exporting video without audio."); audio_remove(); } } auto framesListPath = temporaryDirectory / path::from_utf8(std::format( "anm2ed_frames_{}_{}.txt", std::hash{}(pathString), SDL_GetTicks())) ; std::ofstream framesListFile(framesListPath); if (!framesListFile) { audio_remove(); return false; } auto defaultFrameDuration = 1.0 / (double)fps; for (std::size_t index = 0; index < framePaths.size(); ++index) { auto framePath = framePaths[index]; auto framePathString = path::to_utf8(framePath); auto frameDuration = defaultFrameDuration; framesListFile << "file '" << ffmpeg_concat_escape(framePathString) << "'\n"; framesListFile << "duration " << std::format("{:.9f}", frameDuration) << "\n"; } auto lastFramePathString = path::to_utf8(framePaths.back()); framesListFile << "file '" << ffmpeg_concat_escape(lastFramePathString) << "'\n"; framesListFile.close(); auto framesListPathString = path::to_utf8(framesListPath); command = std::format("\"{0}\" -y -f concat -safe 0 -i \"{1}\"", ffmpegPathString, framesListPathString); if (!audioInputArguments.empty()) command += " " + audioInputArguments; command += std::format(" -fps_mode cfr -r {}", fps); switch (type) { case render::GIF: command += " -lavfi \"split[s0][s1];[s0]palettegen=stats_mode=full:reserve_transparent=1[p];" "[s1][p]paletteuse=dither=floyd_steinberg:alpha_threshold=128\"" " -loop 0"; command += std::format(" \"{}\"", pathString); break; case render::WEBM: command += " -c:v libvpx-vp9 -crf 30 -b:v 0 -pix_fmt yuva420p -row-mt 1 -threads 0 -speed 2 -auto-alt-ref 0"; if (!audioOutputArguments.empty()) command += " " + audioOutputArguments; command += std::format(" \"{}\"", pathString); break; case render::MP4: command += " -vf \"format=yuv420p,scale=trunc(iw/2)*2:trunc(ih/2)*2\" -c:v libx265 -crf 20 -preset slow" " -tag:v hvc1 -movflags +faststart"; if (!audioOutputArguments.empty()) command += " " + audioOutputArguments; command += std::format(" \"{}\"", pathString); break; default: { std::error_code ec; std::filesystem::remove(framesListPath, ec); return false; } } #if _WIN32 command = string::quote(command); #endif logger.command(command); #if _WIN32 Process process(command.c_str(), "wb"); #else Process process(command.c_str(), "w"); #endif if (!process.get()) { std::error_code ec; std::filesystem::remove(framesListPath, ec); audio_remove(); return false; } auto ffmpegExitCode = process.close(); if (ffmpegExitCode != 0) { logger.error(std::format("FFmpeg exited with code {} while exporting {}", ffmpegExitCode, pathString)); std::error_code ec; std::filesystem::remove(framesListPath, ec); audio_remove(); return false; } std::error_code ec; std::filesystem::remove(framesListPath, ec); audio_remove(); return true; } }