chatgui: воспроизведение видео — FFmpeg-плеер + frameless окно
- videoplayer_engine: декод в worker-потоке (FFmpeg), A/V sync по аудио-курсору, seek с epoch-защитой от устаревших кадров - videoplayer_window: окно без рамки, drag-перенос, dblclick/Alt+Enter fullscreen, Esc закрытие, timeline + play/pause/close, авто-скрытие контролов - видео-баббл в списке: превью-кадр (jpg), play-оверлей, длительность, прогресс - превью извлекается после скачивания (video_extract_thumbnail) в фоне - mainwindow: синглтон-плеер, openVideoRequested → openVideo - CMake: HAVE_FFMPEG для vibechat
This commit is contained in:
1 parent
e264b8aaa7
commit
6542618927
11 files changed
+1168
-1
No files matched your search
@@ -0,0 +1,450 @@
|
||||
#include "videoplayer_engine.h"
|
||||
#include "sound_manager.h"
|
||||
#include "../../lib/debug_config.h"
|
||||
|
||||
#include <QThread>
|
||||
#include <QDateTime>
|
||||
|
||||
#ifdef HAVE_FFMPEG
|
||||
extern "C" {
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/avutil.h>
|
||||
#include <libavutil/imgutils.h>
|
||||
#include <libavutil/channel_layout.h>
|
||||
#include <libavutil/samplefmt.h>
|
||||
#include <libswscale/swscale.h>
|
||||
#include <libswresample/swresample.h>
|
||||
}
|
||||
#endif
|
||||
|
||||
static const int kMaxInFlight = 24;
|
||||
|
||||
/* ── Worker: демюкс + декод в отдельном потоке ── */
|
||||
|
||||
class Worker : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
QString path;
|
||||
QAtomicInt* abort = nullptr;
|
||||
QAtomicInt* paused = nullptr;
|
||||
QAtomicInt* seekReq = nullptr;
|
||||
QAtomicInteger<qint64>* seekTargetMs = nullptr;
|
||||
QAtomicInt* inFlight = nullptr;
|
||||
QAtomicInt* epoch = nullptr;
|
||||
|
||||
signals:
|
||||
void opened(int width, int height, qint64 durationMs, bool hasAudio);
|
||||
void audioDecoded(std::shared_ptr<std::vector<int16_t>> pcm, int sampleRate, int channels, qint64 durationMs);
|
||||
void frameDecoded(const QImage& image, qint64 ptsMs, int epoch);
|
||||
void error(const QString& msg);
|
||||
void finished();
|
||||
|
||||
public slots:
|
||||
void run();
|
||||
};
|
||||
|
||||
#ifdef HAVE_FFMPEG
|
||||
|
||||
static void worker_fail(Worker* w, const QString& msg) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoEngine: %s", qPrintable(msg));
|
||||
emit w->error(msg);
|
||||
}
|
||||
|
||||
void Worker::run() {
|
||||
AVFormatContext* fmt = nullptr;
|
||||
AVCodecContext* vdec = nullptr;
|
||||
AVCodecContext* adec = nullptr;
|
||||
struct SwsContext* sws = nullptr;
|
||||
SwrContext* swr = nullptr;
|
||||
AVFrame* frame = nullptr;
|
||||
AVFrame* aframe = nullptr;
|
||||
AVPacket* pkt = nullptr;
|
||||
int vidx = -1, aidx = -1;
|
||||
|
||||
auto cleanup = [&]() {
|
||||
if (sws) sws_freeContext(sws);
|
||||
if (swr) swr_free(&swr);
|
||||
if (frame) av_frame_free(&frame);
|
||||
if (aframe) av_frame_free(&aframe);
|
||||
if (pkt) av_packet_free(&pkt);
|
||||
if (vdec) avcodec_free_context(&vdec);
|
||||
if (adec) avcodec_free_context(&adec);
|
||||
if (fmt) avformat_close_input(&fmt);
|
||||
};
|
||||
|
||||
if (avformat_open_input(&fmt, path.toUtf8().constData(), nullptr, nullptr) != 0) {
|
||||
worker_fail(this, QString("cannot open: %1").arg(path)); return;
|
||||
}
|
||||
if (avformat_find_stream_info(fmt, nullptr) < 0) {
|
||||
worker_fail(this, QString("no stream info: %1").arg(path)); cleanup(); return;
|
||||
}
|
||||
|
||||
for (unsigned i = 0; i < fmt->nb_streams; i++) {
|
||||
AVCodecParameters* p = fmt->streams[i]->codecpar;
|
||||
if (p->codec_type == AVMEDIA_TYPE_VIDEO && vidx < 0) vidx = (int)i;
|
||||
else if (p->codec_type == AVMEDIA_TYPE_AUDIO && aidx < 0) aidx = (int)i;
|
||||
}
|
||||
if (vidx < 0) { worker_fail(this, QString("no video stream: %1").arg(path)); cleanup(); return; }
|
||||
|
||||
AVStream* vst = fmt->streams[vidx];
|
||||
int vw = vst->codecpar->width, vh = vst->codecpar->height;
|
||||
qint64 durMs = 0;
|
||||
if (fmt->duration != AV_NOPTS_VALUE) durMs = fmt->duration / 1000;
|
||||
else if (vst->duration != AV_NOPTS_VALUE && vst->time_base.num > 0)
|
||||
durMs = av_rescale_q(vst->duration, vst->time_base, AVRational{1, 1000});
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: opened %s %dx%d dur=%lldms audio=%d",
|
||||
qPrintable(path), vw, vh, (long long)durMs, aidx >= 0 ? 1 : 0);
|
||||
emit opened(vw, vh, durMs, aidx >= 0);
|
||||
|
||||
/* ── 1. декодируем весь аудио-трек в PCM (мастер-часы) ── */
|
||||
std::shared_ptr<std::vector<int16_t>> audioPcm;
|
||||
int asr = 0, ach = 0;
|
||||
if (aidx >= 0) {
|
||||
AVStream* ast = fmt->streams[aidx];
|
||||
const AVCodec* ac = avcodec_find_decoder(ast->codecpar->codec_id);
|
||||
adec = avcodec_alloc_context3(ac);
|
||||
if (!ac || !adec || avcodec_parameters_to_context(adec, ast->codecpar) < 0 ||
|
||||
avcodec_open2(adec, ac, nullptr) < 0) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: audio decoder open failed, video only");
|
||||
avcodec_free_context(&adec);
|
||||
aidx = -1;
|
||||
}
|
||||
}
|
||||
if (aidx >= 0) {
|
||||
asr = adec->sample_rate;
|
||||
ach = adec->ch_layout.nb_channels;
|
||||
if (asr <= 0 || ach <= 0) { DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: bad audio fmt"); avcodec_free_context(&adec); aidx = -1; }
|
||||
}
|
||||
if (aidx >= 0) {
|
||||
audioPcm = std::make_shared<std::vector<int16_t>>();
|
||||
audioPcm->reserve((size_t)(durMs > 0 ? (durMs * asr * ach / 1000) + 4096 : 4096));
|
||||
|
||||
int rc = swr_alloc_set_opts2(&swr, &adec->ch_layout, AV_SAMPLE_FMT_S16, asr,
|
||||
&adec->ch_layout, adec->sample_fmt, adec->sample_rate, 0, nullptr);
|
||||
if (rc < 0 || swr_init(swr) < 0) {
|
||||
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: swr init failed, video only");
|
||||
swr_free(&swr); avcodec_free_context(&adec); aidx = -1; audioPcm.reset();
|
||||
}
|
||||
}
|
||||
if (aidx >= 0) {
|
||||
aframe = av_frame_alloc();
|
||||
pkt = av_packet_alloc();
|
||||
if (!aframe || !pkt) { worker_fail(this, "alloc failed"); cleanup(); return; }
|
||||
|
||||
while (av_read_frame(fmt, pkt) >= 0) {
|
||||
if (abort->loadRelaxed()) { av_packet_unref(pkt); cleanup(); return; }
|
||||
if (pkt->stream_index == aidx) {
|
||||
if (avcodec_send_packet(adec, pkt) == 0) {
|
||||
while (avcodec_receive_frame(adec, aframe) == 0) {
|
||||
int outSamples = av_rescale_rnd(swr_get_delay(swr, aframe->sample_rate) + aframe->nb_samples,
|
||||
asr, aframe->sample_rate, AV_ROUND_UP);
|
||||
uint8_t* out = nullptr;
|
||||
av_samples_alloc(&out, nullptr, ach, outSamples, AV_SAMPLE_FMT_S16, 0);
|
||||
int got = swr_convert(swr, &out, outSamples,
|
||||
(const uint8_t**)aframe->data, aframe->nb_samples);
|
||||
if (got > 0) {
|
||||
size_t cur = audioPcm->size();
|
||||
audioPcm->resize(cur + (size_t)got * ach);
|
||||
memcpy(audioPcm->data() + cur, out, (size_t)got * ach * sizeof(int16_t));
|
||||
}
|
||||
av_freep(&out);
|
||||
}
|
||||
}
|
||||
}
|
||||
av_packet_unref(pkt);
|
||||
}
|
||||
av_packet_free(&pkt); pkt = nullptr;
|
||||
av_frame_free(&aframe); aframe = nullptr;
|
||||
avcodec_free_context(&adec); adec = nullptr;
|
||||
swr_free(&swr); swr = nullptr;
|
||||
|
||||
qint64 audioDurMs = audioPcm->size() / (size_t)ach * 1000 / asr;
|
||||
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: audio decoded %zu frames %dHz %dch %.1fs",
|
||||
audioPcm->size() / (size_t)ach, asr, ach, audioDurMs / 1000.0);
|
||||
|
||||
/* вернуть чтение в начало для видео */
|
||||
av_seek_frame(fmt, -1, 0, AVSEEK_FLAG_BACKWARD);
|
||||
emit audioDecoded(audioPcm, asr, ach, audioDurMs);
|
||||
}
|
||||
|
||||
/* ── 2. декодируем видео и шлём кадры ── */
|
||||
{
|
||||
const AVCodec* vc = avcodec_find_decoder(vst->codecpar->codec_id);
|
||||
vdec = avcodec_alloc_context3(vc);
|
||||
if (!vc || !vdec || avcodec_parameters_to_context(vdec, vst->codecpar) < 0 ||
|
||||
avcodec_open2(vdec, vc, nullptr) < 0) {
|
||||
worker_fail(this, "video decoder open failed"); cleanup(); return;
|
||||
}
|
||||
frame = av_frame_alloc();
|
||||
pkt = av_packet_alloc();
|
||||
if (!frame || !pkt) { worker_fail(this, "alloc failed"); cleanup(); return; }
|
||||
sws = sws_getContext(vw, vh, vdec->pix_fmt, vw, vh, AV_PIX_FMT_RGB24,
|
||||
SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||
if (!sws) { worker_fail(this, "sws init failed"); cleanup(); return; }
|
||||
|
||||
auto emitFrame = [&](AVFrame* fr) {
|
||||
QImage img(vw, vh, QImage::Format_RGB888);
|
||||
uint8_t* dst[1] = { img.bits() };
|
||||
int dstStride[1] = { (int)img.bytesPerLine() };
|
||||
sws_scale(sws, fr->data, fr->linesize, 0, vh, dst, dstStride);
|
||||
qint64 ptsMs = av_rescale_q(fr->pts, vst->time_base, AVRational{1, 1000});
|
||||
inFlight->fetchAndAddRelaxed(1);
|
||||
emit frameDecoded(img, ptsMs, epoch->loadRelaxed());
|
||||
};
|
||||
|
||||
bool eof = false;
|
||||
while (!abort->loadRelaxed() && !eof) {
|
||||
if (seekReq->loadRelaxed()) {
|
||||
qint64 targetMs = seekTargetMs->loadRelaxed();
|
||||
qint64 ts = av_rescale_q(targetMs, AVRational{1, 1000}, vst->time_base);
|
||||
av_seek_frame(fmt, vidx, ts, AVSEEK_FLAG_BACKWARD);
|
||||
avcodec_flush_buffers(vdec);
|
||||
seekReq->storeRelaxed(0);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoEngine: seek to %lldms", (long long)targetMs);
|
||||
}
|
||||
if (paused->loadRelaxed()) { QThread::msleep(10); continue; }
|
||||
if (inFlight->loadRelaxed() >= kMaxInFlight) { QThread::msleep(3); continue; }
|
||||
|
||||
int ret = av_read_frame(fmt, pkt);
|
||||
if (ret < 0) {
|
||||
avcodec_send_packet(vdec, nullptr);
|
||||
while (avcodec_receive_frame(vdec, frame) == 0) emitFrame(frame);
|
||||
eof = true;
|
||||
break;
|
||||
}
|
||||
if (pkt->stream_index == vidx) {
|
||||
avcodec_send_packet(vdec, pkt);
|
||||
while (avcodec_receive_frame(vdec, frame) == 0) emitFrame(frame);
|
||||
}
|
||||
av_packet_unref(pkt);
|
||||
}
|
||||
|
||||
if (!abort->loadRelaxed()) {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: decode finished");
|
||||
emit finished();
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
#else /* HAVE_FFMPEG */
|
||||
|
||||
void Worker::run() {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoEngine: built without FFmpeg");
|
||||
emit error(QStringLiteral("видео недоступно (сборка без FFmpeg)"));
|
||||
}
|
||||
|
||||
#endif /* HAVE_FFMPEG */
|
||||
|
||||
/* ── VideoPlayerEngine ── */
|
||||
|
||||
VideoPlayerEngine::VideoPlayerEngine(QObject* parent) : QObject(parent) {
|
||||
qRegisterMetaType<QImage>("QImage");
|
||||
qRegisterMetaType<std::shared_ptr<std::vector<int16_t>>>("SharedPcmPtr");
|
||||
m_presentTimer.setInterval(16);
|
||||
connect(&m_presentTimer, &QTimer::timeout, this, &VideoPlayerEngine::presentTick);
|
||||
}
|
||||
|
||||
VideoPlayerEngine::~VideoPlayerEngine() {
|
||||
close();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::open(const QString& path) {
|
||||
close();
|
||||
m_path = path;
|
||||
m_abort.storeRelaxed(0);
|
||||
m_paused.storeRelaxed(0);
|
||||
m_seekReq.storeRelaxed(0);
|
||||
m_inFlight.storeRelaxed(0);
|
||||
clearPending();
|
||||
m_renderFrame = QImage();
|
||||
m_durationMs = 0; m_positionMs = 0; m_hasAudio = false;
|
||||
m_videoWidth = m_videoHeight = 0;
|
||||
m_audioSampleRate = 0;
|
||||
|
||||
m_thread = new QThread(this);
|
||||
m_worker = new Worker();
|
||||
m_worker->path = path;
|
||||
m_worker->abort = &m_abort;
|
||||
m_worker->paused = &m_paused;
|
||||
m_worker->seekReq = &m_seekReq;
|
||||
m_worker->seekTargetMs = &m_seekTargetMs;
|
||||
m_worker->inFlight = &m_inFlight;
|
||||
m_worker->epoch = &m_epoch;
|
||||
m_worker->moveToThread(m_thread);
|
||||
|
||||
connect(m_thread, &QThread::started, m_worker, &Worker::run);
|
||||
connect(m_worker, &Worker::opened, this, &VideoPlayerEngine::onWorkerOpened);
|
||||
connect(m_worker, &Worker::audioDecoded, this, &VideoPlayerEngine::onAudioDecoded);
|
||||
connect(m_worker, &Worker::frameDecoded, this, &VideoPlayerEngine::onFrameDecoded);
|
||||
connect(m_worker, &Worker::error, this, &VideoPlayerEngine::onWorkerError);
|
||||
connect(m_worker, &Worker::finished, this, &VideoPlayerEngine::onWorkerFinished);
|
||||
connect(m_worker, &Worker::finished, m_thread, &QThread::quit);
|
||||
|
||||
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: open %s", qPrintable(path));
|
||||
m_thread->start();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::play() {
|
||||
if (m_state != Paused && m_state != Playing) return;
|
||||
m_paused.storeRelaxed(0);
|
||||
if (m_hasAudio) SoundManager::instance()->resumeCurrentPcm();
|
||||
else { m_elapsedBaseMs = m_positionMs; m_elapsed.restart(); }
|
||||
setState(Playing);
|
||||
m_presentTimer.start();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::pause() {
|
||||
if (m_state != Playing) return;
|
||||
m_paused.storeRelaxed(1);
|
||||
if (m_hasAudio) SoundManager::instance()->pauseCurrentPcm();
|
||||
setState(Paused);
|
||||
m_presentTimer.stop();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::togglePlayPause() {
|
||||
if (m_state == Playing) pause();
|
||||
else if (m_state == Paused) play();
|
||||
else if (m_state == Finished) { seekMs(0); play(); }
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::seekMs(qint64 ms) {
|
||||
if (ms < 0) ms = 0;
|
||||
if (m_durationMs > 0 && ms > m_durationMs) ms = m_durationMs;
|
||||
m_epoch.fetchAndAddRelaxed(1);
|
||||
m_seekTargetMs.storeRelaxed(ms);
|
||||
m_seekReq.storeRelaxed(1);
|
||||
clearPending();
|
||||
if (m_hasAudio) {
|
||||
int sr = SoundManager::instance()->currentPcmSampleRate();
|
||||
if (sr > 0) SoundManager::instance()->seekCurrentPcm((unsigned long long)(ms * sr / 1000));
|
||||
} else {
|
||||
m_elapsedBaseMs = ms;
|
||||
m_elapsed.restart();
|
||||
}
|
||||
m_positionMs = ms;
|
||||
m_inFlight.storeRelaxed(0);
|
||||
if (m_state == Finished) setState(Paused);
|
||||
emit positionChanged(ms);
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::stop() {
|
||||
m_abort.storeRelaxed(1);
|
||||
m_presentTimer.stop();
|
||||
clearPending();
|
||||
m_renderFrame = QImage();
|
||||
if (m_hasAudio) SoundManager::instance()->stopCurrentPcm();
|
||||
setState(Stopped);
|
||||
m_positionMs = 0;
|
||||
m_durationMs = 0;
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::close() {
|
||||
stop();
|
||||
if (m_thread) {
|
||||
m_abort.storeRelaxed(1);
|
||||
m_thread->quit();
|
||||
m_thread->wait(2000);
|
||||
delete m_worker;
|
||||
m_worker = nullptr;
|
||||
delete m_thread;
|
||||
m_thread = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
qint64 VideoPlayerEngine::clockMs() const {
|
||||
if (m_hasAudio && m_audioSampleRate > 0) {
|
||||
unsigned long long cur = SoundManager::instance()->currentPcmCursor();
|
||||
return (qint64)(cur * 1000 / (unsigned long long)m_audioSampleRate);
|
||||
}
|
||||
return m_elapsedBaseMs + m_elapsed.elapsed();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::setState(State s) {
|
||||
if (m_state == s) return;
|
||||
m_state = s;
|
||||
emit stateChanged((int)s);
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::clearPending() {
|
||||
m_pending.clear();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::onWorkerOpened(int width, int height, qint64 durationMs, bool hasAudio) {
|
||||
m_videoWidth = width;
|
||||
m_videoHeight = height;
|
||||
m_durationMs = durationMs;
|
||||
m_hasAudio = hasAudio;
|
||||
emit opened(width, height, durationMs, hasAudio);
|
||||
if (!hasAudio) {
|
||||
m_elapsedBaseMs = 0;
|
||||
m_elapsed.restart();
|
||||
setState(Playing);
|
||||
m_presentTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::onAudioDecoded(std::shared_ptr<std::vector<int16_t>> pcm,
|
||||
int sampleRate, int channels, qint64 durationMs) {
|
||||
if (pcm && !pcm->empty() && channels > 0) {
|
||||
m_audioSampleRate = sampleRate;
|
||||
SoundManager::instance()->playRawPcm(pcm->data(), (int)(pcm->size() / (size_t)channels),
|
||||
sampleRate, channels);
|
||||
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "VideoEngine: audio started %dHz %dch", sampleRate, channels);
|
||||
} else {
|
||||
m_hasAudio = false;
|
||||
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "VideoEngine: no audio data, video only");
|
||||
}
|
||||
m_elapsedBaseMs = 0;
|
||||
m_elapsed.restart();
|
||||
setState(Playing);
|
||||
m_presentTimer.start();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::onFrameDecoded(const QImage& image, qint64 ptsMs, int epoch) {
|
||||
m_inFlight.fetchAndSubRelaxed(1);
|
||||
if (epoch != m_epoch.loadRelaxed()) return; /* устаревший кадр после seek */
|
||||
m_pending.push_back({ image, ptsMs });
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::onWorkerError(const QString& msg) {
|
||||
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "VideoEngine: %s", qPrintable(msg));
|
||||
m_presentTimer.stop();
|
||||
setState(Stopped);
|
||||
emit error(msg);
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::onWorkerFinished() {
|
||||
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "VideoEngine: worker finished");
|
||||
/* дождёмся, пока отрисуются оставшиеся кадры в presentTick, потом Finished */
|
||||
setState(Finished);
|
||||
emit finished();
|
||||
}
|
||||
|
||||
void VideoPlayerEngine::presentTick() {
|
||||
qint64 clk = clockMs();
|
||||
QImage show;
|
||||
bool have = false;
|
||||
while (!m_pending.empty()) {
|
||||
if (m_state != Finished && m_pending.front().second > clk) break;
|
||||
show = m_pending.front().first;
|
||||
m_pending.pop_front();
|
||||
have = true;
|
||||
}
|
||||
if (have) { m_renderFrame = show; emit framePresented(); }
|
||||
|
||||
if (m_state == Finished && m_pending.empty()) {
|
||||
m_positionMs = m_durationMs > 0 ? m_durationMs : clk;
|
||||
emit positionChanged(m_positionMs);
|
||||
m_presentTimer.stop();
|
||||
return;
|
||||
}
|
||||
m_positionMs = clk;
|
||||
if (m_durationMs > 0 && clk > m_durationMs) m_positionMs = m_durationMs;
|
||||
emit positionChanged(m_positionMs);
|
||||
}
|
||||
|
||||
#include "videoplayer_engine.moc"
|
||||
Reference in new issue
Block a user