First pass at framerip

This commit is contained in:
mattmcw 2026-04-04 12:33:05 -04:00
commit 25af5c0079
10 changed files with 907 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build

86
CMakeLists.txt Normal file
View File

@ -0,0 +1,86 @@
cmake_minimum_required(VERSION 3.16)
project(video_frame_extractor LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Source files
add_executable(video_frame_extractor
main.cpp
frame_extractor.cpp
tiff_writer.cpp
platform_utils.cpp
)
# FFmpeg (libav)
#
# Strategy (in order of precedence):
# 1. pkg-config works out of the box on most Linux/macOS (Homebrew) setups.
# 2. Manual paths via CMake cache variables FFMPEG_INCLUDE_DIR / FFMPEG_LIB_DIR.
# Useful on Windows (vcpkg, manual builds, etc.).
find_package(PkgConfig QUIET)
if(PkgConfig_FOUND)
pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec)
pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat)
pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil)
pkg_check_modules(SWSCALE REQUIRED IMPORTED_TARGET libswscale)
target_link_libraries(video_frame_extractor PRIVATE
PkgConfig::AVCODEC
PkgConfig::AVFORMAT
PkgConfig::AVUTIL
PkgConfig::SWSCALE
)
else()
# Fallback: manual path configuration
# Set these variables when invoking CMake, e.g.:
# cmake -DFFMPEG_INCLUDE_DIR=C:/ffmpeg/include \
# -DFFMPEG_LIB_DIR=C:/ffmpeg/lib ..
set(FFMPEG_INCLUDE_DIR "" CACHE PATH "FFmpeg include directory")
set(FFMPEG_LIB_DIR "" CACHE PATH "FFmpeg library directory")
if(NOT FFMPEG_INCLUDE_DIR OR NOT FFMPEG_LIB_DIR)
message(FATAL_ERROR
"pkg-config not found and FFMPEG_INCLUDE_DIR / FFMPEG_LIB_DIR are not set.\n"
"Please install pkg-config or pass the FFmpeg paths manually.")
endif()
target_include_directories(video_frame_extractor PRIVATE "${FFMPEG_INCLUDE_DIR}")
foreach(_lib avcodec avformat avutil swscale)
find_library(_found_${_lib}
NAMES ${_lib}
PATHS "${FFMPEG_LIB_DIR}"
NO_DEFAULT_PATH
)
if(NOT _found_${_lib})
message(FATAL_ERROR "Could not find lib${_lib} in ${FFMPEG_LIB_DIR}")
endif()
target_link_libraries(video_frame_extractor PRIVATE "${_found_${_lib}}")
endforeach()
endif()
# Platform-specific adjustments
if(WIN32)
# Suppress MSVC warnings about C runtime functions and enable Unicode APIs
target_compile_definitions(video_frame_extractor PRIVATE
_CRT_SECURE_NO_WARNINGS
UNICODE
_UNICODE
)
endif()
if(APPLE)
# Silence deprecation warnings that FFmpeg headers can trigger on macOS
target_compile_options(video_frame_extractor PRIVATE -Wno-deprecated-declarations)
endif()
# Install
install(TARGETS video_frame_extractor RUNTIME DESTINATION bin)

150
README.md Normal file
View File

@ -0,0 +1,150 @@
# video_frame_extractor
Extracts every frame from a video file and writes them as uncompressed RGB
TIFF images into a temporary directory. Compiles on **macOS**, **Linux**, and
**Windows**.
## Dependencies
| Library | Purpose |
|---------|---------|
| `libavformat` | Container demuxing |
| `libavcodec` | Video decoding |
| `libavutil` | FFmpeg utilities |
| `libswscale` | Pixel-format conversion |
All four are part of [FFmpeg](https://ffmpeg.org/). No other external libraries
are required — TIFF files are written with a hand-rolled encoder.
---
## Building
### macOS (Homebrew)
```bash
brew install ffmpeg cmake
git clone <this-repo>
cd video_frame_extractor
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
### Linux (apt)
```bash
sudo apt update
sudo apt install ffmpeg libavcodec-dev libavformat-dev \
libavutil-dev libswscale-dev cmake build-essential
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
### Linux (dnf / Fedora)
```bash
sudo dnf install ffmpeg-devel cmake gcc-c++
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
```
### Windows (vcpkg + MSVC)
1. Install [vcpkg](https://github.com/microsoft/vcpkg) and integrate it:
```powershell
git clone https://github.com/microsoft/vcpkg
cd vcpkg
.\bootstrap-vcpkg.bat
.\vcpkg integrate install
.\vcpkg install ffmpeg:x64-windows
```
2. Build:
```powershell
cmake -B build -DCMAKE_TOOLCHAIN_FILE=C:\vcpkg\scripts\buildsystems\vcpkg.cmake `
-DVCPKG_TARGET_TRIPLET=x64-windows `
-DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release
```
### Windows (manual FFmpeg build / pre-built binaries)
Download pre-built FFmpeg dev libs from https://github.com/BtbN/FFmpeg-Builds/releases
and pass their paths to CMake:
```powershell
cmake -B build `
-DFFMPEG_INCLUDE_DIR="C:\ffmpeg\include" `
-DFFMPEG_LIB_DIR="C:\ffmpeg\lib" `
-DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release
```
---
## Usage
```
video_frame_extractor <video_file>
```
**Example:**
```bash
./build/video_frame_extractor sample.mp4
```
Output:
```
Output directory: /tmp/vfe_frames_aBcDeF
Video: sample.mp4
Stream duration: 12.34s | Resolution: 1920x1080
Extracting frames...
Written frame 0 -> /tmp/vfe_frames_aBcDeF/frame_00000000.tif
Written frame 100 -> /tmp/vfe_frames_aBcDeF/frame_00000100.tif
...
Done. Extracted 370 frame(s) to:
/tmp/vfe_frames_aBcDeF
```
Frames are named `frame_XXXXXXXX.tif` (zero-padded to 8 digits).
---
## TIFF format details
Each file is a **baseline TIFF 6.0** image:
| Property | Value |
|----------|-------|
| Colour space | RGB |
| Bits per sample | 8 per channel (24-bit) |
| Compression | None (1) |
| Planar config | Chunky (interleaved) |
| Byte order | Little-endian |
| Strip layout | Single strip (full image) |
These files open correctly in Photoshop, GIMP, Preview, Windows Photo Viewer,
ImageMagick, and any other TIFF-capable application.
---
## Project structure
```
video_frame_extractor/
├── CMakeLists.txt # Cross-platform build script
├── main.cpp # Entry point
├── frame_extractor.h/.cpp # libav decoding + RGB conversion
├── tiff_writer.h/.cpp # Dependency-free TIFF encoder
└── platform_utils.h/.cpp # Temp-dir + path helpers (Win/POSIX)
```
## License
MIT — do whatever you like with it.

240
frame_extractor.cpp Normal file
View File

@ -0,0 +1,240 @@
#include "frame_extractor.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>
}
#include <stdexcept>
#include <iostream>
// ── RAII wrappers ────────────────────────────────────────────────────────────
struct FormatContextGuard {
AVFormatContext* ctx = nullptr;
~FormatContextGuard() { if (ctx) avformat_close_input(&ctx); }
};
struct CodecContextGuard {
AVCodecContext* ctx = nullptr;
~CodecContextGuard() { if (ctx) avcodec_free_context(&ctx); }
};
struct FrameGuard {
AVFrame* frame = nullptr;
FrameGuard() : frame(av_frame_alloc()) {}
~FrameGuard() { if (frame) av_frame_free(&frame); }
};
struct PacketGuard {
AVPacket* pkt = nullptr;
PacketGuard() : pkt(av_packet_alloc()) {}
~PacketGuard() { if (pkt) av_packet_free(&pkt); }
};
struct SwsContextGuard {
SwsContext* ctx = nullptr;
~SwsContextGuard() { if (ctx) sws_freeContext(ctx); }
};
// ── Impl ─────────────────────────────────────────────────────────────────────
struct FrameExtractor::Impl {
std::string path;
FormatContextGuard fmt;
CodecContextGuard codec;
int videoStreamIndex = -1;
int width = 0;
int height = 0;
double duration = 0.0;
};
// ── Public API ────────────────────────────────────────────────────────────────
FrameExtractor::FrameExtractor(std::string path)
: m_path(std::move(path))
{
d = new Impl();
d->path = m_path;
}
FrameExtractor::~FrameExtractor()
{
delete d;
}
bool FrameExtractor::open()
{
// Open container
if (avformat_open_input(&d->fmt.ctx, d->path.c_str(), nullptr, nullptr) < 0) {
std::cerr << "[FrameExtractor] Cannot open file: " << d->path << "\n";
return false;
}
if (avformat_find_stream_info(d->fmt.ctx, nullptr) < 0) {
std::cerr << "[FrameExtractor] Cannot find stream info.\n";
return false;
}
// Find best video stream
const AVCodec* codec = nullptr;
d->videoStreamIndex = av_find_best_stream(
d->fmt.ctx, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, 0);
if (d->videoStreamIndex < 0 || !codec) {
std::cerr << "[FrameExtractor] No video stream found.\n";
return false;
}
AVStream* stream = d->fmt.ctx->streams[d->videoStreamIndex];
// Create and configure codec context
d->codec.ctx = avcodec_alloc_context3(codec);
if (!d->codec.ctx) {
std::cerr << "[FrameExtractor] Cannot allocate codec context.\n";
return false;
}
if (avcodec_parameters_to_context(d->codec.ctx, stream->codecpar) < 0) {
std::cerr << "[FrameExtractor] Cannot copy codec parameters.\n";
return false;
}
if (avcodec_open2(d->codec.ctx, codec, nullptr) < 0) {
std::cerr << "[FrameExtractor] Cannot open codec.\n";
return false;
}
d->width = d->codec.ctx->width;
d->height = d->codec.ctx->height;
// Duration in seconds
if (d->fmt.ctx->duration != AV_NOPTS_VALUE) {
d->duration = static_cast<double>(d->fmt.ctx->duration) / AV_TIME_BASE;
}
return true;
}
void FrameExtractor::forEachFrame(FrameCallback cb)
{
if (d->videoStreamIndex < 0) {
throw std::runtime_error("FrameExtractor::open() must succeed before forEachFrame()");
}
const int W = d->width;
const int H = d->height;
const AVPixelFormat srcFmt = d->codec.ctx->pix_fmt;
// SwsContext for pixel-format conversion → RGB24
SwsContextGuard sws;
sws.ctx = sws_getContext(
W, H, srcFmt,
W, H, AV_PIX_FMT_RGB24,
SWS_BILINEAR, nullptr, nullptr, nullptr);
if (!sws.ctx) {
throw std::runtime_error("sws_getContext failed");
}
// Allocate a dedicated RGB frame (destination)
FrameGuard rgbFrame;
if (!rgbFrame.frame) throw std::runtime_error("av_frame_alloc failed");
const int bufSize = av_image_get_buffer_size(AV_PIX_FMT_RGB24, W, H, 1);
std::vector<uint8_t> rgbBuffer(static_cast<size_t>(bufSize));
av_image_fill_arrays(
rgbFrame.frame->data, rgbFrame.frame->linesize,
rgbBuffer.data(), AV_PIX_FMT_RGB24, W, H, 1);
FrameGuard rawFrame;
PacketGuard pkt;
FrameData fd;
fd.width = W;
fd.height = H;
fd.pixels.resize(static_cast<size_t>(W) * H * 3);
bool keepGoing = true;
while (keepGoing && av_read_frame(d->fmt.ctx, pkt.pkt) >= 0) {
if (pkt.pkt->stream_index != d->videoStreamIndex) {
av_packet_unref(pkt.pkt);
continue;
}
if (avcodec_send_packet(d->codec.ctx, pkt.pkt) < 0) {
av_packet_unref(pkt.pkt);
continue;
}
av_packet_unref(pkt.pkt);
int ret;
while ((ret = avcodec_receive_frame(d->codec.ctx, rawFrame.frame)) >= 0) {
// Convert to RGB24
sws_scale(
sws.ctx,
rawFrame.frame->data, rawFrame.frame->linesize, 0, H,
rgbFrame.frame->data, rgbFrame.frame->linesize);
// Copy into FrameData (packed, no stride padding)
const uint8_t* src = rgbFrame.frame->data[0];
const int srcStride = rgbFrame.frame->linesize[0];
uint8_t* dst = fd.pixels.data();
const int rowBytes = W * 3;
for (int y = 0; y < H; ++y) {
std::copy(src, src + rowBytes, dst);
src += srcStride;
dst += rowBytes;
}
fd.pts = rawFrame.frame->pts;
keepGoing = cb(fd);
av_frame_unref(rawFrame.frame);
if (!keepGoing) break;
}
if (ret != AVERROR(EAGAIN) && ret != AVERROR_EOF && ret < 0) {
break; // real decode error
}
}
// Flush decoder
if (keepGoing) {
avcodec_send_packet(d->codec.ctx, nullptr);
int ret;
while ((ret = avcodec_receive_frame(d->codec.ctx, rawFrame.frame)) >= 0) {
sws_scale(
sws.ctx,
rawFrame.frame->data, rawFrame.frame->linesize, 0, H,
rgbFrame.frame->data, rgbFrame.frame->linesize);
const uint8_t* src = rgbFrame.frame->data[0];
const int srcStride = rgbFrame.frame->linesize[0];
uint8_t* dst = fd.pixels.data();
const int rowBytes = W * 3;
for (int y = 0; y < H; ++y) {
std::copy(src, src + rowBytes, dst);
src += srcStride;
dst += rowBytes;
}
fd.pts = rawFrame.frame->pts;
av_frame_unref(rawFrame.frame);
if (!cb(fd)) break;
}
}
}
int FrameExtractor::width() const { return d->width; }
int FrameExtractor::height() const { return d->height; }
double FrameExtractor::durationSeconds() const { return d->duration; }

50
frame_extractor.h Normal file
View File

@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
// Raw decoded frame: packed RGB24 pixels, top-to-bottom, no padding.
struct FrameData {
std::vector<uint8_t> pixels; // width * height * 3 bytes (RGB)
int width = 0;
int height = 0;
int64_t pts = 0; // presentation timestamp (stream time-base units)
};
// Callback invoked for every decoded frame.
// Return true to continue, false to stop early.
using FrameCallback = std::function<bool(const FrameData&)>;
/**
* Opens a video file with libavformat/libavcodec, seeks to the first video
* stream, and decodes every frame in display order, converting each to RGB24
* via libswscale before handing it to the caller.
*/
class FrameExtractor {
public:
explicit FrameExtractor(std::string path);
~FrameExtractor();
// Disallow copy
FrameExtractor(const FrameExtractor&) = delete;
FrameExtractor& operator=(const FrameExtractor&) = delete;
// Open the file and locate the video stream. Returns false on error.
bool open();
// Decode every frame and invoke cb for each one.
void forEachFrame(FrameCallback cb);
// Metadata accessors (valid after open())
int width() const;
int height() const;
double durationSeconds() const;
private:
struct Impl;
Impl* d = nullptr;
std::string m_path;
};

82
main.cpp Normal file
View File

@ -0,0 +1,82 @@
/**
* video_frame_extractor
*
* Extracts every frame from a video file and writes them as raw TIFF images.
* Compiles on macOS, Linux, and Windows.
*
* Usage:
* video_frame_extractor <video_file> [output_dir]
*
* If output_dir is omitted, a unique temporary directory is created and its
* path is printed. If output_dir is provided it must already exist.
*
* Dependencies: FFmpeg (libavcodec, libavformat, libavutil, libswscale)
*
* Build:
* See CMakeLists.txt
*/
#include "frame_extractor.h"
#include "tiff_writer.h"
#include "platform_utils.h"
#include <iostream>
#include <string>
int main(int argc, char* argv[])
{
if (argc < 2) {
std::cerr << "Usage: " << argv[0] << " <video_file> [output_dir]\n";
return 1;
}
const std::string inputPath = argv[1];
// Determine output directory: use the supplied path or create a temp dir.
std::string outputDir;
if (argc >= 3) {
outputDir = argv[2];
if (!platform::directoryExists(outputDir)) {
std::cerr << "Output directory does not exist: " << outputDir << "\n";
return 1;
}
std::cout << "Output directory: " << outputDir << "\n";
} else {
if (!platform::createTempDirectory("vfe_frames_", outputDir)) {
std::cerr << "Failed to create temporary directory.\n";
return 1;
}
std::cout << "Output directory: " << outputDir << " (temporary)\n";
}
// Open video
FrameExtractor extractor(inputPath);
if (!extractor.open()) {
std::cerr << "Failed to open video: " << inputPath << "\n";
return 1;
}
std::cout << "Video: " << inputPath << "\n"
<< "Stream duration: " << extractor.durationSeconds() << "s | "
<< "Resolution: " << extractor.width() << "x" << extractor.height() << "\n"
<< "Extracting frames...\n";
TiffWriter writer(outputDir);
uint64_t frameCount = 0;
extractor.forEachFrame([&](const FrameData& frame) {
const std::string filename = writer.write(frame, frameCount);
if (filename.empty()) {
std::cerr << "Warning: failed to write frame " << frameCount << "\n";
} else if (frameCount % 100 == 0) {
std::cout << " Written frame " << frameCount
<< " -> " << filename << "\n";
}
++frameCount;
return true; // return false to stop early
});
std::cout << "Done. Extracted " << frameCount << " frame(s) to:\n"
<< " " << outputDir << "\n";
return 0;
}

92
platform_utils.cpp Normal file
View File

@ -0,0 +1,92 @@
#include "platform_utils.h"
#include <cstring>
#include <stdexcept>
#include <string>
// ── Windows ──────────────────────────────────────────────────────────────────
#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <wchar.h>
namespace platform {
char pathSeparator() { return '\\'; }
bool directoryExists(const std::string& path)
{
const int wlen = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0);
if (wlen <= 0) return false;
std::wstring wpath(static_cast<size_t>(wlen - 1), L'\0');
MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, &wpath[0], wlen);
const DWORD attrs = GetFileAttributesW(wpath.c_str());
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY);
}
bool createTempDirectory(const std::string& prefix, std::string& outPath)
{
wchar_t tempBase[MAX_PATH + 1];
if (GetTempPathW(MAX_PATH, tempBase) == 0) return false;
std::wstring wPrefix(prefix.begin(), prefix.end());
wchar_t tempFile[MAX_PATH + 1];
if (GetTempFileNameW(tempBase, wPrefix.c_str(), 0, tempFile) == 0) return false;
DeleteFileW(tempFile);
if (!CreateDirectoryW(tempFile, nullptr)) return false;
const int len = WideCharToMultiByte(CP_UTF8, 0, tempFile, -1, nullptr, 0, nullptr, nullptr);
if (len <= 0) return false;
outPath.resize(static_cast<size_t>(len - 1));
WideCharToMultiByte(CP_UTF8, 0, tempFile, -1, &outPath[0], len, nullptr, nullptr);
return true;
}
} // namespace platform
// ── POSIX (macOS + Linux) ─────────────────────────────────────────────────
#else
#include <cerrno>
#include <cstdlib>
#include <sys/stat.h>
#include <unistd.h>
namespace platform {
char pathSeparator() { return '/'; }
bool directoryExists(const std::string& path)
{
struct stat st{};
return stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
}
bool createTempDirectory(const std::string& prefix, std::string& outPath)
{
const char* tmpEnv = std::getenv("TMPDIR");
std::string base = (tmpEnv && *tmpEnv) ? tmpEnv : "/tmp";
if (!base.empty() && base.back() == '/')
base.pop_back();
// mkdtemp needs a mutable char*. In C++17, std::string::data() is
// non-const and the buffer is null-terminated, so we can use it directly
// without a separate std::vector<char> copy.
std::string tmpl = base + "/" + prefix + "XXXXXX";
if (mkdtemp(tmpl.data()) == nullptr) return false;
outPath = tmpl;
return true;
}
} // namespace platform
#endif

26
platform_utils.h Normal file
View File

@ -0,0 +1,26 @@
#pragma once
#include <string>
namespace platform {
/**
* Returns the OS-appropriate path separator character ('/' or '\\').
*/
char pathSeparator();
/**
* Returns true if the given path exists and is a directory.
*/
bool directoryExists(const std::string& path);
/**
* Create a uniquely-named subdirectory inside the system temporary directory.
*
* @param prefix Name prefix for the new directory.
* @param outPath Receives the full path of the created directory on success.
* @return True on success, false on failure.
*/
bool createTempDirectory(const std::string& prefix, std::string& outPath);
} // namespace platform

154
tiff_writer.cpp Normal file
View File

@ -0,0 +1,154 @@
/**
* Minimal, dependency-free TIFF writer.
*
* Produces uncompressed, baseline TIFF 6.0 files (little-endian).
* Layout:
* [8-byte header] [pixel data] [01 pad byte] [IFD entry count (2 bytes)]
* [N × 12-byte IFD entries] [next-IFD offset = 0 (4 bytes)]
* [BitsPerSample data: 3 × SHORT]
*
* Placing pixel data before the IFD keeps the strip offset a fixed constant
* (always 8) and avoids a two-pass write. A padding byte is inserted when
* imageSize is odd so the IFD starts on a word boundary, as the TIFF spec
* requires.
*/
#include "tiff_writer.h"
#include "platform_utils.h"
#include <cstdint>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <sstream>
#include <vector>
// ── TIFF helpers ──────────────────────────────────────────────────────────────
namespace {
constexpr uint16_t TIFF_SHORT = 3;
constexpr uint16_t TIFF_LONG = 4;
constexpr uint16_t TAG_IMAGE_WIDTH = 256;
constexpr uint16_t TAG_IMAGE_LENGTH = 257;
constexpr uint16_t TAG_BITS_PER_SAMPLE = 258;
constexpr uint16_t TAG_COMPRESSION = 259;
constexpr uint16_t TAG_PHOTOMETRIC = 262;
constexpr uint16_t TAG_STRIP_OFFSETS = 273;
constexpr uint16_t TAG_SAMPLES_PER_PIXEL = 277;
constexpr uint16_t TAG_ROWS_PER_STRIP = 278;
constexpr uint16_t TAG_STRIP_BYTE_COUNTS = 279;
constexpr uint16_t TAG_PLANAR_CONFIG = 284;
template <typename T>
void writeLE(std::vector<uint8_t>& buf, size_t offset, T value)
{
static_assert(std::is_integral<T>::value, "Integral required");
for (size_t i = 0; i < sizeof(T); ++i)
buf[offset + i] = static_cast<uint8_t>((value >> (8 * i)) & 0xFF);
}
template <typename T>
void appendLE(std::vector<uint8_t>& buf, T value)
{
const size_t pos = buf.size();
buf.resize(pos + sizeof(T));
writeLE(buf, pos, value);
}
void appendIFDEntry(std::vector<uint8_t>& buf,
uint16_t tag, uint16_t type,
uint32_t count, uint32_t valueOrOffset)
{
appendLE<uint16_t>(buf, tag);
appendLE<uint16_t>(buf, type);
appendLE<uint32_t>(buf, count);
appendLE<uint32_t>(buf, valueOrOffset);
}
} // namespace
// ── TiffWriter ────────────────────────────────────────────────────────────────
TiffWriter::TiffWriter(const std::string& outputDir)
: m_dir(outputDir)
{}
std::string TiffWriter::write(const FrameData& frame, uint64_t frameIndex)
{
if (frame.pixels.empty() || frame.width <= 0 || frame.height <= 0)
return "";
std::ostringstream nameStream;
nameStream << "frame_" << std::setw(8) << std::setfill('0') << frameIndex << ".tif";
const std::string filePath = m_dir + platform::pathSeparator() + nameStream.str();
// ── Offsets ───────────────────────────────────────────────────────────
const uint32_t W = static_cast<uint32_t>(frame.width);
const uint32_t H = static_cast<uint32_t>(frame.height);
const uint32_t imageSize = W * 3u * H;
// The TIFF spec requires IFDs to begin on a word (2-byte) boundary.
// Insert a 1-byte pad when imageSize is odd.
const uint32_t padBytes = imageSize % 2u;
constexpr uint32_t HEADER_SIZE = 8u;
constexpr uint16_t NUM_ENTRIES = 10u; // exactly the entries written below
constexpr uint32_t IFD_SIZE = 2u + NUM_ENTRIES * 12u + 4u;
const uint32_t pixelOffset = HEADER_SIZE;
const uint32_t ifdOffset = pixelOffset + imageSize + padBytes;
const uint32_t bpsOffset = ifdOffset + IFD_SIZE; // 3 × SHORT after IFD
// ── Assemble in memory ────────────────────────────────────────────────
std::vector<uint8_t> tiff;
tiff.reserve(bpsOffset + 6u);
// Header
appendLE<uint8_t> (tiff, 0x49); // 'I' little-endian
appendLE<uint8_t> (tiff, 0x49); // 'I'
appendLE<uint16_t>(tiff, 42u); // TIFF magic number
appendLE<uint32_t>(tiff, ifdOffset); // offset of first IFD
// Pixel data
tiff.insert(tiff.end(), frame.pixels.begin(), frame.pixels.end());
// Alignment pad (0 or 1 byte)
if (padBytes)
appendLE<uint8_t>(tiff, 0u);
// IFD entry count (must match NUM_ENTRIES exactly)
appendLE<uint16_t>(tiff, NUM_ENTRIES);
// IFD entries — ascending tag order required by the spec
appendIFDEntry(tiff, TAG_IMAGE_WIDTH, TIFF_LONG, 1u, W);
appendIFDEntry(tiff, TAG_IMAGE_LENGTH, TIFF_LONG, 1u, H);
appendIFDEntry(tiff, TAG_BITS_PER_SAMPLE, TIFF_SHORT, 3u, bpsOffset); // → extra data
appendIFDEntry(tiff, TAG_COMPRESSION, TIFF_SHORT, 1u, 1u); // no compression
appendIFDEntry(tiff, TAG_PHOTOMETRIC, TIFF_SHORT, 1u, 2u); // RGB
appendIFDEntry(tiff, TAG_STRIP_OFFSETS, TIFF_LONG, 1u, pixelOffset);
appendIFDEntry(tiff, TAG_SAMPLES_PER_PIXEL, TIFF_SHORT, 1u, 3u);
appendIFDEntry(tiff, TAG_ROWS_PER_STRIP, TIFF_LONG, 1u, H); // single strip
appendIFDEntry(tiff, TAG_STRIP_BYTE_COUNTS, TIFF_LONG, 1u, imageSize);
appendIFDEntry(tiff, TAG_PLANAR_CONFIG, TIFF_SHORT, 1u, 1u); // chunky / interleaved
// Next-IFD offset = 0 (this is the only IFD)
appendLE<uint32_t>(tiff, 0u);
// Extra data: BitsPerSample values (8, 8, 8)
appendLE<uint16_t>(tiff, 8u); // R
appendLE<uint16_t>(tiff, 8u); // G
appendLE<uint16_t>(tiff, 8u); // B
// ── Write to disk ─────────────────────────────────────────────────────
std::ofstream out(filePath, std::ios::binary);
if (!out) return "";
out.write(reinterpret_cast<const char*>(tiff.data()),
static_cast<std::streamsize>(tiff.size()));
if (!out) return "";
return filePath;
}

26
tiff_writer.h Normal file
View File

@ -0,0 +1,26 @@
#pragma once
#include "frame_extractor.h"
#include <cstdint>
#include <string>
/**
* Writes FrameData objects as uncompressed RGB TIFF files.
* No external library required the TIFF structure is written by hand.
* This produces baseline TIFF 6.0 files that every TIFF reader understands.
*/
class TiffWriter {
public:
explicit TiffWriter(const std::string& outputDir);
/**
* Write a single frame.
* @param frame Source pixel data (RGB24, packed).
* @param frameIndex Zero-based frame number used to build the filename.
* @return Full path of the written file, or "" on error.
*/
std::string write(const FrameData& frame, uint64_t frameIndex);
private:
std::string m_dir;
};