feat: complete `uninstall.sh` logic to make everything synced. feat: complete basic play logic to handle background, music, lyric play.
541 lines
18 KiB
C++
541 lines
18 KiB
C++
// 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 <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#ifdef _WIN32
|
|
#include <io.h>
|
|
#include <windows.h>
|
|
#else
|
|
#include <sys/ioctl.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
extern "C" {
|
|
#include <libavcodec/avcodec.h>
|
|
#include <libavformat/avformat.h>
|
|
#include <libavutil/error.h>
|
|
#include <libavutil/imgutils.h>
|
|
#include <libavutil/log.h>
|
|
#include <libavutil/pixfmt.h>
|
|
#include <libswscale/swscale.h>
|
|
}
|
|
|
|
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<int>(ws.ws_col);
|
|
ts.rows = static_cast<int>(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<int>(std::lround(cols * static_cast<double>(videoH) /
|
|
(2.0 * videoW)));
|
|
if (rows > maxRows) {
|
|
rows = maxRows;
|
|
cols = static_cast<int>(std::floor(rows * 2.0 * videoW / videoH));
|
|
rows = std::min(rows, static_cast<int>(std::lround(
|
|
cols * static_cast<double>(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<uint8_t> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(mutex_);
|
|
return queue_.empty();
|
|
}
|
|
|
|
void setFinished() {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
finished_ = true;
|
|
cv_.notify_all();
|
|
}
|
|
|
|
private:
|
|
mutable std::mutex mutex_;
|
|
std::condition_variable cv_;
|
|
std::deque<RgbFrame> 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<uint8_t> dstBuf;
|
|
uint8_t* dstData[4] = {};
|
|
int dstLinesize[4] = {};
|
|
|
|
AVFrame* frame = nullptr;
|
|
AVPacket* pkt = nullptr;
|
|
|
|
bool loop = false;
|
|
BoundedQueue* queue = nullptr;
|
|
const std::atomic<bool>* stop = nullptr;
|
|
std::atomic<bool>* 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<double>(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<size_t>(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<size_t>(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<size_t>(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<size_t>(offsetX), ' ');
|
|
|
|
const uint8_t* row = base + static_cast<size_t>(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<size_t>(x) * 3;
|
|
const uint8_t r = row[i], g = row[i + 1], b = row[i + 2];
|
|
|
|
const int fr = static_cast<int>(r * factor);
|
|
const int fg = static_cast<int>(g * factor);
|
|
const int fb = static_cast<int>(b * factor);
|
|
if (fr != lastFg[0] || fg != lastFg[1] || fb != lastFg[2]) {
|
|
appendColor(out, false, static_cast<uint8_t>(fr), static_cast<uint8_t>(fg),
|
|
static_cast<uint8_t>(fb));
|
|
lastFg[0] = fr;
|
|
lastFg[1] = fg;
|
|
lastFg[2] = fb;
|
|
}
|
|
|
|
const int br = static_cast<int>(r * factor * 0.16f);
|
|
const int bg = static_cast<int>(g * factor * 0.16f);
|
|
const int bb = static_cast<int>(b * factor * 0.16f);
|
|
if (br != lastBg[0] || bg != lastBg[1] || bb != lastBg[2]) {
|
|
appendColor(out, true, static_cast<uint8_t>(br), static_cast<uint8_t>(bg),
|
|
static_cast<uint8_t>(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<int>(std::lround(
|
|
ts.cols * static_cast<double>(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<bool> 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<size_t>(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::steady_clock::duration>(
|
|
std::chrono::duration<double>(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
|