// backgroundDisplay.cpp // // Decodes a video file with FFmpeg and plays it in the terminal as truecolor // ASCII art. See backgroundDisplay.h for the API and the adjustable/not- // adjustable terminal sizing logic. // // Decoding runs on a background thread; frames are scaled straight to the // final grid size and pushed into a small bounded queue that always keeps the // newest frame (dropping old ones), so the producer never blocks. The calling // thread paces rendering at the video's frame rate. #include "backgroundDisplay.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #include #else #include #include #endif extern "C" { #include #include #include #include #include #include #include } namespace bd { // --- low-level terminal access -------------------------------------------- namespace { void writeRaw(const std::string& s) { fwrite(s.data(), 1, s.size(), stdout); fflush(stdout); } std::string ansiCursorTo(int row, int col) { return "\x1b[" + std::to_string(row) + ";" + std::to_string(col) + "H"; } std::string ansiResize(int rows, int cols) { return "\x1b[8;" + std::to_string(rows) + ";" + std::to_string(cols) + "t"; } } // namespace bool isTerminal() { #ifdef _WIN32 return _isatty(_fileno(stdout)) != 0; #else return isatty(STDOUT_FILENO) != 0; #endif } TermSize terminalSize() { TermSize ts; #ifdef _WIN32 HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_SCREEN_BUFFER_INFO csbi; if (h != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(h, &csbi)) { ts.cols = csbi.srWindow.Right - csbi.srWindow.Left + 1; ts.rows = csbi.srWindow.Bottom - csbi.srWindow.Top + 1; ts.ok = (ts.cols > 0 && ts.rows > 0); } #else struct winsize ws; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0) { ts.cols = static_cast(ws.ws_col); ts.rows = static_cast(ws.ws_row); ts.ok = true; } #endif return ts; } bool resizeTerminal(int rows, int cols) { if (!isTerminal()) return false; const TermSize before = terminalSize(); if (!before.ok) return false; writeRaw(ansiResize(rows, cols)); // Give the terminal emulator a moment to honour the request. std::this_thread::sleep_for(std::chrono::milliseconds(150)); const TermSize after = terminalSize(); // Adjustable = the terminal actually changed size in response. return after.ok && (after.rows != before.rows || after.cols != before.cols); } bool isTerminalAdjustable() { if (!isTerminal()) return false; const TermSize ts = terminalSize(); if (!ts.ok) return false; // Probe with a different height than the current one, so any response is // unambiguous, then restore the original size. const int probeRows = ts.rows + 1; writeRaw(ansiResize(probeRows, ts.cols)); std::this_thread::sleep_for(std::chrono::milliseconds(150)); const TermSize after = terminalSize(); const bool adjustable = after.ok && after.rows != ts.rows; writeRaw(ansiResize(ts.rows, ts.cols)); return adjustable; } GridSize fitSizeForAspect(int videoW, int videoH, int maxCols, int maxRows) { GridSize g; if (videoW <= 0 || videoH <= 0 || maxCols <= 0 || maxRows <= 0) return g; // Terminal cells are about twice as tall as they are wide, so a video of // W x H needs roughly `cols * H / (2 * W)` rows. int cols = maxCols; int rows = static_cast(std::lround(cols * static_cast(videoH) / (2.0 * videoW))); if (rows > maxRows) { rows = maxRows; cols = static_cast(std::floor(rows * 2.0 * videoW / videoH)); rows = std::min(rows, static_cast(std::lround( cols * static_cast(videoH) / (2.0 * videoW)))); } g.cols = std::max(1, cols); g.rows = std::max(1, rows); return g; } // --- decode pipeline ------------------------------------------------------- namespace { // Luminance ramp, darkest -> brightest. Mirrors the reference asciivision // project's palette. const char PALETTE[] = " .'`^\",:;Il!i><~+_-?][}{1)(|\\tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"; const size_t PALETTE_LEN = sizeof(PALETTE) - 1; struct RgbFrame { int width = 0; int height = 0; std::vector rgb; // width * height * 3, tightly packed RGB24 }; // Bounded queue that always keeps the newest frame. pushLatest() never blocks // (drops the oldest), so the producer can't deadlock against the consumer. class BoundedQueue { public: explicit BoundedQueue(size_t capacity) : capacity_(capacity) {} void pushLatest(RgbFrame frame) { std::lock_guard lock(mutex_); while (queue_.size() >= capacity_) queue_.pop_front(); queue_.push_back(std::move(frame)); cv_.notify_one(); } // Wait up to `deadline` for the newest frame. Returns false if none // arrived by then. Drains older frames (only the newest is kept). bool popLatestUntil(std::chrono::steady_clock::time_point deadline, RgbFrame& out) { std::unique_lock lock(mutex_); cv_.wait_until(lock, deadline, [this] { return !queue_.empty() || finished_; }); if (queue_.empty()) return false; out = std::move(queue_.back()); queue_.clear(); return true; } bool empty() const { std::lock_guard lock(mutex_); return queue_.empty(); } void setFinished() { std::lock_guard lock(mutex_); finished_ = true; cv_.notify_all(); } private: mutable std::mutex mutex_; std::condition_variable cv_; std::deque queue_; size_t capacity_; bool finished_ = false; }; struct DecodeContext { AVFormatContext* fmt = nullptr; int videoIndex = -1; AVCodecContext* codec = nullptr; int srcW = 0; int srcH = 0; AVPixelFormat srcPixFmt = AV_PIX_FMT_NONE; SwsContext* sws = nullptr; int dstW = 0; int dstH = 0; std::vector dstBuf; uint8_t* dstData[4] = {}; int dstLinesize[4] = {}; AVFrame* frame = nullptr; AVPacket* pkt = nullptr; bool loop = false; BoundedQueue* queue = nullptr; const std::atomic* stop = nullptr; std::atomic* finished = nullptr; }; void freeDecode(DecodeContext& dc) { if (dc.pkt) av_packet_free(&dc.pkt); if (dc.frame) av_frame_free(&dc.frame); if (dc.codec) avcodec_free_context(&dc.codec); if (dc.fmt) avformat_close_input(&dc.fmt); if (dc.sws) sws_freeContext(dc.sws); dc.pkt = nullptr; dc.frame = nullptr; dc.codec = nullptr; dc.fmt = nullptr; dc.sws = nullptr; } // Open the file, pick the best video stream and open its decoder. Outputs the // source dimensions and the (approximate) frame rate. Returns 0 on success. int probeVideo(const std::string& path, DecodeContext& dc, int& outW, int& outH, double& outFps) { // FFmpeg logs to stderr by default, which would corrupt the TUI. av_log_set_level(AV_LOG_QUIET); if (avformat_open_input(&dc.fmt, path.c_str(), nullptr, nullptr) < 0) return -1; if (avformat_find_stream_info(dc.fmt, nullptr) < 0) return -1; const int index = av_find_best_stream(dc.fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (index < 0) return -1; dc.videoIndex = index; AVStream* stream = dc.fmt->streams[index]; const AVCodec* codec = avcodec_find_decoder(stream->codecpar->codec_id); if (!codec) return -1; dc.codec = avcodec_alloc_context3(codec); if (avcodec_parameters_to_context(dc.codec, stream->codecpar) < 0) return -1; if (avcodec_open2(dc.codec, codec, nullptr) < 0) return -1; dc.srcW = dc.codec->width; dc.srcH = dc.codec->height; dc.srcPixFmt = dc.codec->pix_fmt; const AVRational rate = av_guess_frame_rate(dc.fmt, stream, nullptr); double fps = (rate.num > 0 && rate.den > 0) ? static_cast(rate.num) / rate.den : 0.0; if (fps <= 0.0 || fps > 120.0) fps = 24.0; outW = dc.srcW; outH = dc.srcH; outFps = fps; return 0; } int setupScaler(DecodeContext& dc, int dstW, int dstH) { dc.dstW = dstW; dc.dstH = dstH; dc.sws = sws_getCachedContext(nullptr, dc.srcW, dc.srcH, dc.srcPixFmt, dstW, dstH, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); if (!dc.sws) return -1; // Tightly packed RGB24 (align = 1), so row y starts at y * width * 3. dc.dstBuf.resize(static_cast(dstW) * dstH * 3); if (av_image_fill_arrays(dc.dstData, dc.dstLinesize, dc.dstBuf.data(), AV_PIX_FMT_RGB24, dstW, dstH, 1) < 0) { return -1; } dc.frame = av_frame_alloc(); dc.pkt = av_packet_alloc(); return (dc.frame && dc.pkt) ? 0 : -1; } void scaleAndPush(DecodeContext& dc) { sws_scale(dc.sws, dc.frame->data, dc.frame->linesize, 0, dc.frame->height, dc.dstData, dc.dstLinesize); RgbFrame frame; frame.width = dc.dstW; frame.height = dc.dstH; frame.rgb.assign(dc.dstBuf.begin(), dc.dstBuf.end()); dc.queue->pushLatest(std::move(frame)); } void decoderThreadFn(DecodeContext& dc) { while (!(dc.stop && dc.stop->load())) { const int r = av_read_frame(dc.fmt, dc.pkt); if (r == AVERROR_EOF) { // Flush whatever is buffered in the decoder. avcodec_send_packet(dc.codec, nullptr); while (avcodec_receive_frame(dc.codec, dc.frame) == 0) scaleAndPush(dc); if (dc.loop && !(dc.stop && dc.stop->load())) { av_seek_frame(dc.fmt, -1, 0, AVSEEK_FLAG_BACKWARD); avcodec_flush_buffers(dc.codec); continue; } break; } if (r < 0) break; // non-EOF read error if (dc.pkt->stream_index == dc.videoIndex) { avcodec_send_packet(dc.codec, dc.pkt); while (avcodec_receive_frame(dc.codec, dc.frame) == 0) scaleAndPush(dc); } av_packet_unref(dc.pkt); } if (dc.finished) dc.finished->store(true); dc.queue->setFinished(); } void appendColor(std::string& out, bool background, uint8_t r, uint8_t g, uint8_t b) { char buf[32]; const int n = std::snprintf(buf, sizeof buf, "\x1b[%d;2;%d;%d;%dm", background ? 48 : 38, r, g, b); out.append(buf, static_cast(n)); } char asciiFor(uint8_t r, uint8_t g, uint8_t b) { const double y = 0.299 * r + 0.587 * g + 0.114 * b; const size_t index = (static_cast(y) * (PALETTE_LEN - 1)) / 255; return PALETTE[index]; } // Render one RGB frame into `out` as ANSI truecolor cells. The frame is drawn // at (offsetX, offsetY) (1-based row semantics handled by the caller's cursor // positioning); when `showLabel` is set the source video size (srcW x srcH) // is printed on its own line at the top of the terminal first. void renderRgbFrame(const RgbFrame& frame, float intensity, int offsetX, int offsetY, bool showLabel, int srcW, int srcH, std::string& out) { out.clear(); out += ansiCursorTo(1, 1); if (showLabel) { out += "\x1b[0m"; out += std::to_string(srcW); out += 'x'; out += std::to_string(srcH); out += "\x1b[K"; // erase the rest of the label row } out += ansiCursorTo(offsetY + 1, 1); const uint8_t* base = frame.rgb.data(); const int cols = frame.width; const int rows = frame.height; for (int y = 0; y < rows; ++y) { if (y > 0) out += "\r\n"; if (offsetX > 0) out.append(static_cast(offsetX), ' '); const uint8_t* row = base + static_cast(y) * cols * 3; const float scanline = (y % 2 == 0) ? 0.84f : 1.0f; const float factor = std::clamp(intensity * scanline, 0.1f, 1.2f); int lastFg[3] = {-1, -1, -1}; int lastBg[3] = {-1, -1, -1}; for (int x = 0; x < cols; ++x) { const size_t i = static_cast(x) * 3; const uint8_t r = row[i], g = row[i + 1], b = row[i + 2]; const int fr = static_cast(r * factor); const int fg = static_cast(g * factor); const int fb = static_cast(b * factor); if (fr != lastFg[0] || fg != lastFg[1] || fb != lastFg[2]) { appendColor(out, false, static_cast(fr), static_cast(fg), static_cast(fb)); lastFg[0] = fr; lastFg[1] = fg; lastFg[2] = fb; } const int br = static_cast(r * factor * 0.16f); const int bg = static_cast(g * factor * 0.16f); const int bb = static_cast(b * factor * 0.16f); if (br != lastBg[0] || bg != lastBg[1] || bb != lastBg[2]) { appendColor(out, true, static_cast(br), static_cast(bg), static_cast(bb)); lastBg[0] = br; lastBg[1] = bg; lastBg[2] = bb; } out += asciiFor(r, g, b); } } } } // namespace // --- playback -------------------------------------------------------------- int playVideo(const std::string& path, Timeline* timeline, const PlayVideoOptions& opts) { if (!isTerminal()) return 2; // Fallback clock for callers that don't provide one. Timeline localTimeline; if (!timeline) { timeline = &localTimeline; timeline->start(); } DecodeContext dc; int videoW = 0, videoH = 0; double fps = 24.0; if (probeVideo(path, dc, videoW, videoH, fps) < 0) { freeDecode(dc); return 1; } TermSize ts = terminalSize(); if (!ts.ok) { freeDecode(dc); return 2; } // Decide the ASCII grid size and whether to print the frame-size label. int gridW = 0, gridH = 0; int offsetX = 0, offsetY = 0; bool showLabel = false; const int topRows = opts.topRows > 0 ? opts.topRows : 0; const int bottomRows = opts.bottomRows > 0 ? opts.bottomRows : 0; bool adjustable = false; if (opts.tryResize) { int aspectRows = static_cast(std::lround( ts.cols * static_cast(videoH) / (2.0 * videoW))); aspectRows = std::clamp(aspectRows, 2, 200); adjustable = resizeTerminal(aspectRows + topRows + bottomRows, ts.cols); if (adjustable) { // The terminal changed; use whatever it actually became. ts = terminalSize(); } } if (adjustable) { gridW = ts.cols; gridH = std::max(1, ts.rows - topRows - bottomRows); offsetY = topRows; } else { // Not adjustable (or resize disabled): fit proportionally into the // current size. When the top is reserved for other content (lyrics), // the frame-size label is suppressed; otherwise it gets its own line // at the top. showLabel = (topRows == 0); const int labelRows = showLabel ? 1 : 0; const int maxRows = std::max(1, ts.rows - topRows - bottomRows - labelRows); GridSize g = fitSizeForAspect(videoW, videoH, ts.cols, maxRows); gridW = g.cols; gridH = g.rows; offsetX = (ts.cols - gridW) / 2; offsetY = topRows + (maxRows - gridH) / 2; } if (opts.outGrid) { opts.outGrid->cols = gridW; opts.outGrid->rows = gridH; } if (opts.outOffsetX) *opts.outOffsetX = offsetX; if (opts.gridReady) opts.gridReady->store(true); if (gridW <= 0 || gridH <= 0 || setupScaler(dc, gridW, gridH) < 0) { freeDecode(dc); return 1; } // Hide the cursor and clear the screen. writeRaw("\x1b[?25l\x1b[2J\x1b[H"); std::atomic finished(false); BoundedQueue queue(4); dc.queue = &queue; dc.stop = opts.stop; dc.finished = &finished; dc.loop = opts.loop; std::thread decoder(decoderThreadFn, std::ref(dc)); const double intervalSec = 1.0 / fps; std::string out; out.reserve(static_cast(gridW) * gridH * 24); // Render one (latest) frame per time slot, paced by the shared timeline. // `nextSlot` jumps past any missed slots, so a slow decode never causes a // catch-up burst of redraws. double nextSlot = 0.0; while (!(opts.stop && opts.stop->load())) { RgbFrame frame; const auto deadline = timeline->startTime() + std::chrono::duration_cast( std::chrono::duration(nextSlot)); const bool got = queue.popLatestUntil(deadline, frame); // Wait until the timeline reaches this slot (unless stopped). while (timeline->seconds() < nextSlot) { if (opts.stop && opts.stop->load()) break; std::this_thread::sleep_for(std::chrono::milliseconds(5)); } if (opts.stop && opts.stop->load()) break; if (got) { renderRgbFrame(frame, opts.intensity, offsetX, offsetY, showLabel, videoW, videoH, out); writeRaw(out); } if (finished.load() && queue.empty()) break; const double now = timeline->seconds(); nextSlot = std::max(nextSlot + intervalSec, now + intervalSec); } queue.setFinished(); decoder.join(); writeRaw("\x1b[0m\x1b[?25h"); freeDecode(dc); return 0; } } // namespace bd