Browse Source

video: отправка видео-сообщений (FFmpeg пробинг+транскод H.264/AAC MP4)

- src/video/: video_probe/video_get_info/video_transcode_start (FFmpeg, HAVE_FFMPEG)
- chat_core/chat_msg: media_video флаг, chat_core_submit_media_message, транскод перед index
- headless: команда attach с --video
- chatgui: диалог 'Как видео' + прогресс транскода + sendVideoAttachment
- FFmpeg в сборке (configure.ac, Makefile.am, chatgui CMake, build_nix.sh)
- debug: категория DEBUG_CATEGORY_VIDEO
v2
evgeny 3 weeks ago
parent
commit
1855c61275
  1. 17
      build.sh
  2. 38
      configure.ac
  3. 1
      lib/debug_config.c
  4. 3
      lib/debug_config.h
  5. 8
      src/Makefile.am
  6. 4
      src/chat/chat_core.h
  7. 85
      src/chat/chat_headless_control.c
  8. 43
      src/chat/chat_msg.c
  9. 607
      src/video/video.c
  10. 69
      src/video/video.h
  11. 2
      tests/Makefile.am
  12. 13
      tools/chatcli
  13. 8
      tools/chatcli_commands.txt
  14. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  15. 13
      tools/chatgui/build_nix.sh
  16. 7
      tools/chatgui/libutun/CMakeLists.txt
  17. 67
      tools/chatgui/src/inputbar.cpp
  18. 6
      tools/chatgui/src/inputbar.h
  19. 24
      tools/chatgui/src/messagelist.cpp

17
build.sh

@ -137,16 +137,27 @@ else
fi fi
fi fi
# Auto-detect FFmpeg (optional video transcoding). If libav libraries are
# available, build with --with-ffmpeg; configure gracefully skips it otherwise.
FFMPEG_OPT=""
if command -v pkg-config >/dev/null 2>&1 && \
pkg-config --exists libavformat libavcodec libavutil libswscale libswresample 2>/dev/null; then
FFMPEG_OPT="--with-ffmpeg"
echo "FFmpeg detected — building with video transcoding"
else
echo "FFmpeg not found — building without video transcoding"
fi
# Generate Makefiles if needed # Generate Makefiles if needed
if [ ! -f Makefile ]; then if [ ! -f Makefile ]; then
echo "" echo ""
echo "Running ./configure..." echo "Running ./configure..."
if [ "$USE_ASAN" -eq 1 ]; then if [ "$USE_ASAN" -eq 1 ]; then
./configure CFLAGS="-g -Og -fsanitize=address -fno-omit-frame-pointer -fno-pie" LDFLAGS="-no-pie" ./configure CFLAGS="-g -Og -fsanitize=address -fno-omit-frame-pointer -fno-pie" LDFLAGS="-no-pie" $FFMPEG_OPT
elif [ "$USE_HARDENED" -eq 1 ]; then elif [ "$USE_HARDENED" -eq 1 ]; then
./configure --enable-hardening ./configure --enable-hardening $FFMPEG_OPT
else else
./configure ./configure $FFMPEG_OPT
fi fi
fi fi

38
configure.ac

@ -127,6 +127,44 @@ fi
AM_CONDITIONAL([HAVE_WHISPER], [test "x$with_whisper" = "xyes"]) AM_CONDITIONAL([HAVE_WHISPER], [test "x$with_whisper" = "xyes"])
AC_SUBST([WHISPER_LIBS]) AC_SUBST([WHISPER_LIBS])
# ==================== FFMPEG (OPTIONAL) ====================
AC_ARG_WITH([ffmpeg],
AS_HELP_STRING([--with-ffmpeg], [Enable video transcoding (requires FFmpeg libav libraries)]),
[with_ffmpeg=$withval],
[with_ffmpeg=no])
if test "x$with_ffmpeg" = "xyes"; then
save_LIBS="$LIBS"
FFMPEG_LIBS="-lavformat -lavcodec -lavutil -lswscale -lswresample"
LIBS="$FFMPEG_LIBS $LIBS"
AC_CHECK_HEADER([libavformat/avformat.h], [
AC_CHECK_HEADER([libavcodec/avcodec.h], [
AC_CHECK_HEADER([libavutil/imgutils.h], [
AC_CHECK_HEADER([libswscale/swscale.h], [
AC_CHECK_HEADER([libswresample/swresample.h], [
AC_CHECK_LIB([avformat], [avformat_open_input], [
AC_DEFINE([HAVE_FFMPEG], [1], [Define to enable FFmpeg video transcoding])
AC_MSG_NOTICE([FFmpeg enabled])
], [
AC_MSG_WARN([FFmpeg libs not found, disabling ffmpeg])
with_ffmpeg=no
])
], [AC_MSG_WARN([swresample.h not found, disabling ffmpeg]); with_ffmpeg=no])
], [AC_MSG_WARN([swscale.h not found, disabling ffmpeg]); with_ffmpeg=no])
], [AC_MSG_WARN([imgutils.h not found, disabling ffmpeg]); with_ffmpeg=no])
], [AC_MSG_WARN([avcodec.h not found, disabling ffmpeg]); with_ffmpeg=no])
], [AC_MSG_WARN([avformat.h not found, disabling ffmpeg]); with_ffmpeg=no])
LIBS="$save_LIBS"
fi
if test "x$with_ffmpeg" != "xyes"; then
FFMPEG_LIBS=""
FFMPEG_CFLAGS=""
fi
AM_CONDITIONAL([HAVE_FFMPEG], [test "x$with_ffmpeg" = "xyes"])
AC_SUBST([FFMPEG_LIBS])
AC_SUBST([FFMPEG_CFLAGS])
# Checks for header files, typedefs, functions... # Checks for header files, typedefs, functions...
AC_CHECK_HEADERS([arpa/inet.h fcntl.h limits.h netinet/in.h stdint.h stdlib.h string.h sys/ioctl.h sys/socket.h unistd.h]) AC_CHECK_HEADERS([arpa/inet.h fcntl.h limits.h netinet/in.h stdint.h stdlib.h string.h sys/ioctl.h sys/socket.h unistd.h])
AC_TYPE_SIZE_T AC_TYPE_SIZE_T

1
lib/debug_config.c

@ -129,6 +129,7 @@ static const struct {
{"chat_sync", DEBUG_CATEGORY_CHAT_SYNC}, {"chat_sync", DEBUG_CATEGORY_CHAT_SYNC},
{"member_sync", DEBUG_CATEGORY_MEMBER_SYNC}, {"member_sync", DEBUG_CATEGORY_MEMBER_SYNC},
{"proxy", DEBUG_CATEGORY_PROXY}, {"proxy", DEBUG_CATEGORY_PROXY},
{"video", DEBUG_CATEGORY_VIDEO},
{"all", DEBUG_CATEGORY_ALL}, {"all", DEBUG_CATEGORY_ALL},
{NULL, DEBUG_CATEGORY_NONE} {NULL, DEBUG_CATEGORY_NONE}
}; };

3
lib/debug_config.h

@ -67,7 +67,8 @@ typedef int debug_category_t;
#define DEBUG_CATEGORY_CHAT_SYNC 26 // Message DB sync (INIT_SYNC, PUSH, chain hash, recalc) #define DEBUG_CATEGORY_CHAT_SYNC 26 // Message DB sync (INIT_SYNC, PUSH, chain hash, recalc)
#define DEBUG_CATEGORY_MEMBER_SYNC 27 // Member + address + merkle sync #define DEBUG_CATEGORY_MEMBER_SYNC 27 // Member + address + merkle sync
#define DEBUG_CATEGORY_PROXY 28 // Proxy modules (SOCKS5, TCP, UDP, ICMP) #define DEBUG_CATEGORY_PROXY 28 // Proxy modules (SOCKS5, TCP, UDP, ICMP)
#define DEBUG_CATEGORY_COUNT 29 // Total number of categories #define DEBUG_CATEGORY_VIDEO 29 // Video module (probe/transcode)
#define DEBUG_CATEGORY_COUNT 30 // Total number of categories
#define DEBUG_CATEGORY_ALL (-1) // special value for all categories #define DEBUG_CATEGORY_ALL (-1) // special value for all categories
/* Debug configuration structure */ /* Debug configuration structure */

8
src/Makefile.am

@ -56,6 +56,7 @@ utun_CORE_SOURCES = \
media_delivery/media_download.c \ media_delivery/media_download.c \
media_delivery/media_index.c \ media_delivery/media_index.c \
media_async/media_async.c \ media_async/media_async.c \
video/video.c \
transport_layer/dummynet.c \ transport_layer/dummynet.c \
ntp_time.c \ ntp_time.c \
ntp_node_time.c \ ntp_node_time.c \
@ -141,6 +142,7 @@ libutun_a_SOURCES = \
media_delivery/media_download.c \ media_delivery/media_download.c \
media_delivery/media_index.c \ media_delivery/media_index.c \
media_async/media_async.c \ media_async/media_async.c \
video/video.c \
transport_layer/dummynet.c \ transport_layer/dummynet.c \
ntp_time.c \ ntp_time.c \
ntp_node_time.c \ ntp_node_time.c \
@ -185,6 +187,7 @@ utun_CFLAGS = \
-I$(top_srcdir)/src/routing_layer \ -I$(top_srcdir)/src/routing_layer \
-I$(top_srcdir)/src/media_delivery \ -I$(top_srcdir)/src/media_delivery \
-I$(top_srcdir)/src/media_async \ -I$(top_srcdir)/src/media_async \
-I$(top_srcdir)/src/video \
-I$(top_srcdir)/src/uip \ -I$(top_srcdir)/src/uip \
-g \ -g \
-DUSE_SQLITE \ -DUSE_SQLITE \
@ -206,6 +209,11 @@ utun_CFLAGS += -DHAVE_WHISPER
utun_LDADD += @WHISPER_LIBS@ utun_LDADD += @WHISPER_LIBS@
endif endif
if HAVE_FFMPEG
utun_CFLAGS += -DHAVE_FFMPEG @FFMPEG_CFLAGS@
utun_LDADD += @FFMPEG_LIBS@
endif
# Copy binary to project root after building # Copy binary to project root after building
all-local: copy-to-root all-local: copy-to-root

4
src/chat/chat_core.h

@ -32,6 +32,7 @@ struct chat_msg_submit {
char media_src[1024]; char media_src[1024];
char media_dest[1024]; char media_dest[1024];
uint8_t media_copy; uint8_t media_copy;
uint8_t media_video; /* 1 = подготовить как видео (пробинг+транскод) */
uint8_t* data; uint8_t* data;
uint32_t data_len; uint32_t data_len;
uint64_t timestamp; uint64_t timestamp;
@ -41,6 +42,9 @@ struct chat_msg_submit {
void chat_core_submit_message(struct chat_msg_submit* req); void chat_core_submit_message(struct chat_msg_submit* req);
/* Отправка медиа-сообщения (вложение): src->dest (copy) или видео (transcode). */
void chat_core_submit_media_message(struct chat_msg_submit* req);
/* ── DB-операции (оставлены для интроспекции) ── */ /* ── DB-операции (оставлены для интроспекции) ── */
uint32_t chat_core_count(const char* ch_id); uint32_t chat_core_count(const char* ch_id);

85
src/chat/chat_headless_control.c

@ -24,6 +24,8 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <stdio.h> #include <stdio.h>
#include <time.h>
#include <sys/stat.h>
#include "../../lib/platform_compat.h" #include "../../lib/platform_compat.h"
#include "../../lib/socket_compat.h" #include "../../lib/socket_compat.h"
@ -301,6 +303,85 @@ static void hc_handle_send(struct headless_client* cli, int id, const char* json
send_response(cli, id, "{\"sent\":true}", NULL); send_response(cli, id, "{\"sent\":true}", NULL);
} }
static int hc_mkdirs(const char* path) {
char tmp[1024]; snprintf(tmp, sizeof(tmp), "%s", path);
size_t len = strlen(tmp);
for (size_t i = 1; i < len; i++) {
if (tmp[i] != '/') continue;
tmp[i] = '\0';
if (utun_mkdir(tmp, 0755) != 0 && errno != EEXIST) { tmp[i] = '/'; return -1; }
tmp[i] = '/';
}
if (utun_mkdir(tmp, 0755) != 0 && errno != EEXIST) return -1;
return 0;
}
static void hc_handle_attach(struct headless_client* cli, int id, const char* json) {
char ch[64], path[1024], ct[32];
if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; }
if (json_get_str(json, "path", path, sizeof(path)) < 0) { send_response(cli, id, NULL, "missing 'path' param"); return; }
if (json_get_str(json, "content_type", ct, sizeof(ct)) < 0)
snprintf(ct, sizeof(ct), "application/octet-stream");
int as_video = json_get_int(json, "as_video", 0);
struct stat st;
if (stat(path, &st) != 0 || st.st_size <= 0) { send_response(cli, id, NULL, "file not found or empty"); return; }
if (!g_hc.inst || !g_hc.inst->media_async) { send_response(cli, id, NULL, "media engine not initialized"); return; }
/* media_base из ui_state (как в блок-сервере media_delivery) */
sqlite3* db = chat_core_get_db();
char media_base[512] = "/tmp/utun_media";
if (db) {
sqlite3_stmt* ms = NULL;
if (sqlite3_prepare_v2(db, "SELECT value FROM ui_state WHERE key='media_base'", -1, &ms, NULL) == SQLITE_OK) {
if (sqlite3_step(ms) == SQLITE_ROW) {
const char* mb = (const char*)sqlite3_column_text(ms, 0);
if (mb) snprintf(media_base, sizeof(media_base), "%s", mb);
}
sqlite3_finalize(ms);
}
}
const char* base = strrchr(path, '/'); base = base ? base + 1 : path;
char name[256];
{ size_t blen = strlen(base); if (blen >= sizeof(name)) blen = sizeof(name) - 1;
memcpy(name, base, blen); name[blen] = '\0'; }
char dir[640]; snprintf(dir, sizeof(dir), "%s/%s", media_base, ch);
if (hc_mkdirs(dir) != 0) { send_response(cli, id, NULL, "failed to create media dir"); return; }
char dest[1024];
uint64_t ts = (uint64_t)time(NULL);
if (as_video) {
char* dot = strrchr(name, '.'); if (dot) *dot = '\0';
snprintf(dest, sizeof(dest), "%s/%llu_%s.mp4", dir, (unsigned long long)ts, name);
} else {
snprintf(dest, sizeof(dest), "%s/%llu_%s", dir, (unsigned long long)ts, name);
}
struct chat_msg_submit* req = u_calloc(1, sizeof(*req));
if (!req) { send_response(cli, id, NULL, "out of memory"); return; }
snprintf(req->channel_id, sizeof(req->channel_id), "%s", ch);
snprintf(req->content_type, sizeof(req->content_type), "%s", as_video ? "video/mp4" : ct);
snprintf(req->media_src, sizeof(req->media_src), "%s", path);
snprintf(req->media_dest, sizeof(req->media_dest), "%s", dest);
req->media_copy = as_video ? 0 : 1;
req->media_video = as_video ? 1 : 0;
req->data = (uint8_t*)name;
req->data_len = (uint32_t)strlen(name);
DEBUG_INFO((int)DEBUG_CATEGORY_HEADLESS, "headless: attach ch=%s src=%s dst=%s video=%d",
ch, path, dest, as_video);
chat_core_submit_media_message(req);
u_free(req);
char resp[1400]; snprintf(resp, sizeof(resp), "{\"attached\":true,\"dest\":\"%s\",\"video\":%s}",
dest, as_video ? "true" : "false");
send_response(cli, id, resp, NULL);
}
static void hc_handle_invite(struct headless_client* cli, int id, const char* json) { static void hc_handle_invite(struct headless_client* cli, int id, const char* json) {
char ch[64], node_id_str[32]; char ch[64], node_id_str[32];
if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; } if (json_get_str(json, "ch", ch, sizeof(ch)) < 0) { send_response(cli, id, NULL, "missing 'ch' param"); return; }
@ -464,8 +545,8 @@ static void hc_handle_quit(struct headless_client* cli, int id, const char* json
typedef void (*hc_cmd_fn)(struct headless_client* cli, int id, const char* json); typedef void (*hc_cmd_fn)(struct headless_client* cli, int id, const char* json);
static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "quit", NULL }; static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "attach", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "quit", NULL };
static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_quit }; static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_attach, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_quit };
void hc_handle_command(struct headless_client* cli, const char* json) { void hc_handle_command(struct headless_client* cli, const char* json) {
if (!cli || !json) return; if (!cli || !json) return;

43
src/chat/chat_msg.c

@ -13,6 +13,7 @@
#include "../transport_layer/secure_channel.h" #include "../transport_layer/secure_channel.h"
#include "../media_delivery/media_index.h" #include "../media_delivery/media_index.h"
#include "../media_delivery/media_download.h" #include "../media_delivery/media_download.h"
#include "../video/video.h"
#include "../../lib/mem.h" #include "../../lib/mem.h"
#include "../../lib/platform_compat.h" #include "../../lib/platform_compat.h"
@ -22,7 +23,7 @@
chat_whisper_trigger_fn g_chat_whisper_trigger = NULL; chat_whisper_trigger_fn g_chat_whisper_trigger = NULL;
/* forward decl */ /* forward decl */
static void chat_core_submit_media_message(struct chat_msg_submit* req); void chat_core_submit_media_message(struct chat_msg_submit* req);
static void wh_transcribe_done_cb(void* arg, const char* ch_id, const char* text, int err, static void wh_transcribe_done_cb(void* arg, const char* ch_id, const char* text, int err,
uint64_t reply_ts, uint64_t reply_node); uint64_t reply_ts, uint64_t reply_node);
@ -192,13 +193,41 @@ static void on_media_registered(void* arg, int err, const struct media_index_res
u_free(sig_msg); u_free(json); u_free(full_data); u_free(mctx); u_free(sig_msg); u_free(json); u_free(full_data); u_free(mctx);
} }
static void chat_core_submit_media_message(struct chat_msg_submit* req) { static void on_video_prepared(void* arg, int err) {
struct media_submit_ctx* mctx = (struct media_submit_ctx*)arg;
if (err) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: video prepare failed ch=%s err=%d",
CC_ID, mctx->channel_id, err);
u_free(mctx);
return;
}
char media_base[1024];
{
const char* last_slash = strrchr(g_cc.db_path, '/');
if (last_slash)
snprintf(media_base, sizeof(media_base), "%.*s", (int)(last_slash - g_cc.db_path), g_cc.db_path);
else
snprintf(media_base, sizeof(media_base), "%s", g_cc.db_path);
}
media_index_register_async(
g_cc.inst->media_async, g_cc.inst->ua, g_cc.db,
g_cc.my_node_id, g_cc.inst->my_ed25519_privkey,
mctx->channel_id,
mctx->media_dest, mctx->media_dest, 0,
media_base,
on_media_registered, mctx);
}
void chat_core_submit_media_message(struct chat_msg_submit* req) {
if (!g_cc.initialized) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media submit before init", CC_ID); return; } if (!g_cc.initialized) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media submit before init", CC_ID); return; }
if (!req || req->media_src[0] == '\0') return; if (!req || req->media_src[0] == '\0') return;
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: MEDIA SUBMIT ch=%s ct=%s src=%s dst=%s copy=%d", DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "%s: MEDIA SUBMIT ch=%s ct=%s src=%s dst=%s copy=%d video=%d",
CC_ID, req->channel_id, req->content_type, CC_ID, req->channel_id, req->content_type,
req->media_src, req->media_dest, req->media_copy); req->media_src, req->media_dest, req->media_copy, req->media_video);
if (!g_cc.inst->media_async) { if (!g_cc.inst->media_async) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media_async not initialized", CC_ID); DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "%s: media_async not initialized", CC_ID);
@ -227,6 +256,12 @@ static void chat_core_submit_media_message(struct chat_msg_submit* req) {
memcpy(mctx->msg_data, req->data, mctx->msg_data_len); memcpy(mctx->msg_data, req->data, mctx->msg_data_len);
} }
if (req->media_video) {
video_transcode_start(g_cc.inst->ua, req->media_src, req->media_dest,
on_video_prepared, mctx);
return;
}
media_index_register_async( media_index_register_async(
g_cc.inst->media_async, g_cc.inst->ua, g_cc.db, g_cc.inst->media_async, g_cc.inst->ua, g_cc.db,
g_cc.my_node_id, g_cc.inst->my_ed25519_privkey, g_cc.my_node_id, g_cc.inst->my_ed25519_privkey,

607
src/video/video.c

@ -0,0 +1,607 @@
// video.c — пробинг и транскодирование видео через FFmpeg (опционально, HAVE_FFMPEG)
#include "video.h"
#include "../../lib/debug_config.h"
#include "../../lib/mem.h"
#include "../../lib/u_async.h"
#include "../../lib/platform_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <sys/stat.h>
#ifdef HAVE_FFMPEG
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/imgutils.h>
#include <libavutil/opt.h>
#include <libavutil/channel_layout.h>
#include <libavutil/samplefmt.h>
#include <libswscale/swscale.h>
#include <libswresample/swresample.h>
#endif
/* ─── прогресс активного транскода (атомарно, читается из GUI-потока) ─── */
static int g_progress_permille = -1; /* -1 = транскода нет, иначе 0..1000 */
static int g_transcode_active = 0;
static int vc_progress_get(void) { return __atomic_load_n(&g_progress_permille, __ATOMIC_RELAXED); }
static void vc_progress_set(int v) { __atomic_store_n(&g_progress_permille, v, __ATOMIC_RELAXED); }
static int vc_active_get(void) { return __atomic_load_n(&g_transcode_active, __ATOMIC_RELAXED); }
static void vc_active_set(int v) { __atomic_store_n(&g_transcode_active, v, __ATOMIC_RELAXED); }
int video_transcode_get_progress(void) { return vc_progress_get(); }
int video_transcode_is_active(void) { return vc_active_get(); }
#ifndef HAVE_FFMPEG
/* ─── сборка без ffmpeg: стабы ─── */
int video_probe(const char* path) { (void)path; return 0; }
int video_get_info(const char* path, struct video_info* out) { (void)path; (void)out; return -1; }
int video_needs_transcode(const struct video_info* info) { (void)info; return 1; }
int video_transcode_start(struct UASYNC* ua, const char* src, const char* dst,
void (*done)(void* arg, int err), void* arg) {
(void)ua; (void)src; (void)dst;
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: built without FFmpeg (HAVE_FFMPEG undefined)");
if (done) done(arg, -1);
return -1;
}
#else /* HAVE_FFMPEG */
/* ─── одноразовая инициализация ffmpeg ─── */
static pthread_once_t g_ffmpeg_once = PTHREAD_ONCE_INIT;
/* Перехват логов FFmpeg: все сообщения идут в наш debug-лог с категорией video,
* иначе ошибки ffmpeg теряются в stderr и не видно причину сбоя транскода. */
static void video_av_log_cb(void* avcl, int level, const char* fmt, va_list vl) {
(void)avcl;
if (level > AV_LOG_WARNING) return; /* отсекаем INFO/VERBOSE/DEBUG/TRACE — демуксер спамит */
char buf[1024];
vsnprintf(buf, sizeof(buf), fmt, vl);
size_t len = strlen(buf);
while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) buf[--len] = '\0';
if (level <= AV_LOG_ERROR) DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "ffmpeg: %s", buf);
else DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "ffmpeg: %s", buf);
}
static void ffmpeg_init_once(void) {
av_log_set_callback(video_av_log_cb);
av_log_set_level(AV_LOG_WARNING);
avformat_network_init();
}
/* строковое описание кода ошибки ffmpeg (используется только в worker-потоке) */
static const char* vc_errstr(int err) {
static char buf[64];
av_strerror(err, buf, sizeof(buf));
return buf;
}
/* ─── пробинг ─── */
int video_probe(const char* path) {
if (!path || !path[0]) return 0;
pthread_once(&g_ffmpeg_once, ffmpeg_init_once);
AVFormatContext* fmt = NULL;
if (avformat_open_input(&fmt, path, NULL, NULL) != 0) return 0;
if (avformat_find_stream_info(fmt, NULL) < 0) {
avformat_close_input(&fmt);
return 0;
}
int is_video = 0;
for (unsigned i = 0; i < fmt->nb_streams; i++) {
if (fmt->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { is_video = 1; break; }
}
avformat_close_input(&fmt);
DEBUG_DEBUG(DEBUG_CATEGORY_VIDEO, "video: probe %s = %d", path, is_video);
return is_video;
}
int video_get_info(const char* path, struct video_info* out) {
if (!out) return -1;
memset(out, 0, sizeof(*out));
if (!path || !path[0]) return -1;
pthread_once(&g_ffmpeg_once, ffmpeg_init_once);
AVFormatContext* fmt = NULL;
if (avformat_open_input(&fmt, path, NULL, NULL) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: open failed: %s", path);
return -1;
}
if (avformat_find_stream_info(fmt, NULL) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "video: find_stream_info failed: %s", path);
avformat_close_input(&fmt);
return -1;
}
AVStream* vst = NULL;
for (unsigned i = 0; i < fmt->nb_streams; i++) {
AVCodecParameters* p = fmt->streams[i]->codecpar;
if (p->codec_type == AVMEDIA_TYPE_VIDEO && !vst) { vst = fmt->streams[i]; }
else if (p->codec_type == AVMEDIA_TYPE_AUDIO) { out->has_audio = 1; }
}
if (!vst) {
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "video: no video stream: %s", path);
avformat_close_input(&fmt);
return -1;
}
out->is_video = 1;
out->width = vst->codecpar->width;
out->height = vst->codecpar->height;
out->bitrate = vst->codecpar->bit_rate ? vst->codecpar->bit_rate : fmt->bit_rate;
if (fmt->duration != AV_NOPTS_VALUE) out->duration_ms = fmt->duration / 1000;
else if (vst->duration != AV_NOPTS_VALUE && vst->time_base.num > 0)
out->duration_ms = av_rescale_q(vst->duration, vst->time_base, (AVRational){1, 1000});
const AVCodecDescriptor* desc = avcodec_descriptor_get(vst->codecpar->codec_id);
if (desc) snprintf(out->codec, sizeof(out->codec), "%s", desc->name);
if (fmt->iformat && fmt->iformat->name) snprintf(out->container, sizeof(out->container), "%s", fmt->iformat->name);
avformat_close_input(&fmt);
DEBUG_INFO(DEBUG_CATEGORY_VIDEO,
"video: info %s: %dx%d %dkbit codec=%s container=%s dur=%lldms audio=%d",
path, out->width, out->height, (int)(out->bitrate / 1000),
out->codec, out->container, (long long)out->duration_ms, out->has_audio);
return 0;
}
int video_needs_transcode(const struct video_info* info) {
if (!info || !info->is_video) return 1;
if (!strstr(info->container, "mp4") && !strstr(info->container, "mov")) return 1;
if (strcmp(info->codec, "h264") != 0) return 1;
if (info->width > VIDEO_TARGET_MAX_WIDTH || info->height > VIDEO_TARGET_MAX_HEIGHT) return 1;
if (info->bitrate > VIDEO_TARGET_MAX_BITRATE) return 1;
return 0;
}
/* ─── транскодирование ─── */
struct vc_stream {
int in_idx;
AVRational in_time_base; /* time_base входного потока (для рескейла PTS) */
AVCodecContext* dec_ctx;
AVCodecContext* enc_ctx;
AVStream* out_stream;
struct SwsContext* sws;
struct SwrContext* swr;
AVFrame* frame;
int64_t next_pts; /* аудио: счётчик в сэмплах (ед. time_base энкодера) */
};
struct vc_ctx {
char src[1024];
char dst[1024];
struct UASYNC* ua;
void (*done)(void* arg, int err);
void* arg;
int err;
int64_t src_size;
AVFormatContext* ifmt;
AVFormatContext* ofmt;
struct vc_stream vid;
struct vc_stream aud;
};
static int vc_copy_file(const char* src, const char* dst) {
FILE* s = fopen(src, "rb");
if (!s) return -1;
FILE* d = fopen(dst, "wb");
if (!d) { fclose(s); return -1; }
uint8_t buf[65536];
size_t rd;
while ((rd = fread(buf, 1, sizeof(buf), s)) > 0) {
if (fwrite(buf, 1, rd, d) != rd) { fclose(s); fclose(d); return -1; }
}
int rc = ferror(s) ? -1 : 0;
fclose(s);
if (fclose(d) != 0) rc = -1;
return rc;
}
static int vc_open_decoder(struct vc_stream* st, AVFormatContext* ifmt, enum AVMediaType type) {
const char* tname = (type == AVMEDIA_TYPE_VIDEO) ? "video" : "audio";
int idx = av_find_best_stream(ifmt, type, -1, -1, NULL, 0);
if (idx < 0) return -1;
AVCodecParameters* par = ifmt->streams[idx]->codecpar;
const AVCodec* dec = avcodec_find_decoder(par->codec_id);
if (!dec) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: no decoder for %s stream %d (codec_id=%d)", tname, idx, par->codec_id); return -1; }
st->dec_ctx = avcodec_alloc_context3(dec);
if (!st->dec_ctx) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: alloc decoder ctx failed (%s)", tname); return -1; }
if (avcodec_parameters_to_context(st->dec_ctx, par) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: parameters_to_context failed (%s)", tname); return -1;
}
if (avcodec_open2(st->dec_ctx, dec, NULL) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: open decoder failed (%s %s)", tname, dec->name); return -1;
}
st->in_idx = idx;
st->in_time_base = ifmt->streams[idx]->time_base;
st->frame = av_frame_alloc();
if (!st->frame) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: av_frame_alloc failed (%s)", tname); return -1; }
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "video: decoder %s stream %d: %s", tname, idx, dec->name);
return 0;
}
static void vc_stream_close(struct vc_stream* st) {
if (st->sws) { sws_freeContext(st->sws); st->sws = NULL; }
if (st->swr) { swr_free(&st->swr); st->swr = NULL; }
if (st->frame){ av_frame_free(&st->frame); st->frame = NULL; }
if (st->dec_ctx) { avcodec_free_context(&st->dec_ctx); st->dec_ctx = NULL; }
if (st->enc_ctx) { avcodec_free_context(&st->enc_ctx); st->enc_ctx = NULL; }
}
static int vc_open_video(struct vc_ctx* ctx) {
struct vc_stream* st = &ctx->vid;
AVCodecContext* dec = st->dec_ctx;
int dst_w = dec->width, dst_h = dec->height;
double f = 1.0;
if (dst_w > VIDEO_TARGET_MAX_WIDTH) f = (double)VIDEO_TARGET_MAX_WIDTH / dst_w;
if (dst_h * f > VIDEO_TARGET_MAX_HEIGHT) f = (double)VIDEO_TARGET_MAX_HEIGHT / dst_h;
dst_w = (int)(dec->width * f + 0.5);
dst_h = (int)(dec->height * f + 0.5);
dst_w &= ~1; if (dst_w < 2) dst_w = 2;
dst_h &= ~1; if (dst_h < 2) dst_h = 2;
const AVCodec* enc = avcodec_find_encoder_by_name("libx264");
if (!enc) enc = avcodec_find_encoder(AV_CODEC_ID_H264);
if (!enc) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: no H.264 encoder"); return -1; }
st->enc_ctx = avcodec_alloc_context3(enc);
if (!st->enc_ctx) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: alloc encoder ctx failed"); return -1; }
st->enc_ctx->width = dst_w;
st->enc_ctx->height = dst_h;
st->enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P;
st->enc_ctx->time_base = (AVRational){1, 1000};
st->enc_ctx->framerate = av_guess_frame_rate(ctx->ifmt, ctx->ifmt->streams[st->in_idx], NULL);
st->enc_ctx->gop_size = 12 * ((st->enc_ctx->framerate.num + st->enc_ctx->framerate.den - 1) / st->enc_ctx->framerate.den);
if (st->enc_ctx->gop_size < 24) st->enc_ctx->gop_size = 24;
st->enc_ctx->max_b_frames = 0;
st->enc_ctx->bit_rate = VIDEO_TARGET_MAX_BITRATE;
st->enc_ctx->rc_max_rate = VIDEO_TARGET_MAX_BITRATE;
st->enc_ctx->rc_buffer_size = 2 * VIDEO_TARGET_MAX_BITRATE;
AVDictionary* opts = NULL;
av_dict_set(&opts, "preset", "medium", 0);
av_dict_set(&opts, "crf", "23", 0);
if (avcodec_open2(st->enc_ctx, enc, &opts) < 0) {
av_dict_free(&opts);
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: open video encoder failed (%s)", enc->name);
return -1;
}
av_dict_free(&opts);
st->out_stream = avformat_new_stream(ctx->ofmt, NULL);
if (!st->out_stream) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: avformat_new_stream failed (video)"); return -1; }
if (avcodec_parameters_from_context(st->out_stream->codecpar, st->enc_ctx) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: parameters_from_context failed (video)"); return -1;
}
st->out_stream->time_base = st->enc_ctx->time_base;
st->out_stream->avg_frame_rate = st->enc_ctx->framerate;
st->sws = sws_getContext(dec->width, dec->height, dec->pix_fmt,
dst_w, dst_h, AV_PIX_FMT_YUV420P,
SWS_BILINEAR, NULL, NULL, NULL);
if (!st->sws) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: sws_getContext failed"); return -1; }
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "video: transcode video %dx%d -> %dx%d h264 2Mbit",
dec->width, dec->height, dst_w, dst_h);
return 0;
}
static int vc_open_audio(struct vc_ctx* ctx) {
struct vc_stream* st = &ctx->aud;
AVCodecContext* dec = st->dec_ctx;
const AVCodec* enc = avcodec_find_encoder(AV_CODEC_ID_AAC);
if (!enc) { DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "video: no AAC encoder, dropping audio"); return -1; }
st->enc_ctx = avcodec_alloc_context3(enc);
if (!st->enc_ctx) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: alloc audio encoder ctx failed"); return -1; }
st->enc_ctx->bit_rate = VIDEO_TARGET_AUDIO_BITRATE;
st->enc_ctx->sample_rate = dec->sample_rate ? dec->sample_rate : 44100;
st->enc_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP;
if (av_channel_layout_copy(&st->enc_ctx->ch_layout, &dec->ch_layout) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: ch_layout copy failed"); return -1;
}
st->enc_ctx->time_base = (AVRational){1, st->enc_ctx->sample_rate};
if (avcodec_open2(st->enc_ctx, enc, NULL) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: open AAC encoder failed"); return -1;
}
st->out_stream = avformat_new_stream(ctx->ofmt, NULL);
if (!st->out_stream) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: avformat_new_stream failed (audio)"); return -1; }
if (avcodec_parameters_from_context(st->out_stream->codecpar, st->enc_ctx) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: parameters_from_context failed (audio)"); return -1;
}
st->out_stream->time_base = st->enc_ctx->time_base;
if (dec->sample_fmt != st->enc_ctx->sample_fmt ||
dec->sample_rate != st->enc_ctx->sample_rate ||
av_channel_layout_compare(&dec->ch_layout, &st->enc_ctx->ch_layout)) {
int rc = swr_alloc_set_opts2(&st->swr, &st->enc_ctx->ch_layout, st->enc_ctx->sample_fmt,
st->enc_ctx->sample_rate, &dec->ch_layout, dec->sample_fmt,
dec->sample_rate, 0, NULL);
if (rc < 0 || swr_init(st->swr) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: swr init failed"); return -1; }
}
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "video: transcode audio -> AAC 128k %dHz", st->enc_ctx->sample_rate);
return 0;
}
static int vc_encode(struct vc_ctx* ctx, struct vc_stream* st, int flush) {
AVCodecContext* enc = st->enc_ctx;
const char* tname = (enc->codec_type == AVMEDIA_TYPE_VIDEO) ? "video" : "audio";
while (1) {
AVPacket* pkt = av_packet_alloc();
if (!pkt) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: av_packet_alloc failed (%s)", tname); return -1; }
int ret = avcodec_receive_packet(enc, pkt);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
av_packet_free(&pkt);
if (flush && ret == AVERROR(EAGAIN)) continue;
return 0;
}
if (ret < 0) { av_packet_free(&pkt); DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: receive_packet failed (%s): %s", tname, vc_errstr(ret)); return -1; }
av_packet_rescale_ts(pkt, enc->time_base, st->out_stream->time_base);
pkt->stream_index = st->out_stream->index;
int rc = av_interleaved_write_frame(ctx->ofmt, pkt);
av_packet_free(&pkt);
if (rc < 0) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: write_frame failed (%s): %s", tname, vc_errstr(rc)); return -1; }
}
}
static int vc_decode_send(struct vc_ctx* ctx, struct vc_stream* st, AVPacket* pkt, int* eof) {
const char* tname = (st->enc_ctx->codec_type == AVMEDIA_TYPE_VIDEO) ? "video" : "audio";
int ret = avcodec_send_packet(st->dec_ctx, pkt);
if (ret < 0 && ret != AVERROR(EAGAIN)) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: send_packet failed (%s): %s", tname, vc_errstr(ret)); return -1;
}
if (ret == AVERROR_EOF) { *eof = 1; }
while (1) {
ret = avcodec_receive_frame(st->dec_ctx, st->frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
if (ret < 0) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: receive_frame failed (%s): %s", tname, vc_errstr(ret)); return -1; }
AVFrame* src_frame = st->frame;
AVFrame* out_frame = src_frame;
AVFrame* tmp = NULL;
if (st->enc_ctx->codec_type == AVMEDIA_TYPE_VIDEO) {
if (src_frame->width != st->enc_ctx->width || src_frame->height != st->enc_ctx->height ||
src_frame->format != st->enc_ctx->pix_fmt) {
tmp = av_frame_alloc();
if (!tmp) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: av_frame_alloc failed (scale)"); return -1; }
tmp->format = st->enc_ctx->pix_fmt;
tmp->width = st->enc_ctx->width;
tmp->height = st->enc_ctx->height;
if (av_frame_get_buffer(tmp, 0) < 0) { av_frame_free(&tmp); DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: frame_get_buffer failed (scale)"); return -1; }
sws_scale(st->sws, (const uint8_t* const*)src_frame->data, src_frame->linesize,
0, src_frame->height, tmp->data, tmp->linesize);
tmp->pts = src_frame->pts;
out_frame = tmp;
}
} else if (st->enc_ctx->codec_type == AVMEDIA_TYPE_AUDIO) {
if (st->swr) {
tmp = av_frame_alloc();
if (!tmp) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: av_frame_alloc failed (resample)"); return -1; }
tmp->format = st->enc_ctx->sample_fmt;
av_channel_layout_copy(&tmp->ch_layout, &st->enc_ctx->ch_layout);
tmp->sample_rate = st->enc_ctx->sample_rate;
tmp->nb_samples = av_rescale_rnd(swr_get_delay(st->swr, src_frame->sample_rate) + src_frame->nb_samples,
st->enc_ctx->sample_rate, src_frame->sample_rate, AV_ROUND_UP);
if (av_frame_get_buffer(tmp, 0) < 0) { av_frame_free(&tmp); DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: frame_get_buffer failed (resample)"); return -1; }
if (swr_convert_frame(st->swr, tmp, src_frame) < 0) { av_frame_free(&tmp); DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: swr_convert_frame failed"); return -1; }
tmp->pts = st->next_pts;
st->next_pts += tmp->nb_samples;
out_frame = tmp;
}
}
out_frame->pts = (st->enc_ctx->codec_type == AVMEDIA_TYPE_VIDEO)
? av_rescale_q_rnd(out_frame->pts, st->in_time_base, st->enc_ctx->time_base,
AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX)
: out_frame->pts; /* аудио: pts уже в сэмплах time_base энкодера */
if (avcodec_send_frame(st->enc_ctx, out_frame) < 0) {
if (tmp) av_frame_free(&tmp);
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: send_frame failed (%s)", tname);
return -1;
}
if (tmp) av_frame_free(&tmp);
if (vc_encode(ctx, st, 0) < 0) return -1;
}
return 0;
}
static void vc_flush(struct vc_ctx* ctx, struct vc_stream* st) {
avcodec_send_frame(st->enc_ctx, NULL);
vc_encode(ctx, st, 1);
}
static int vc_run_transcode(struct vc_ctx* ctx) {
pthread_once(&g_ffmpeg_once, ffmpeg_init_once);
int ret = avformat_open_input(&ctx->ifmt, ctx->src, NULL, NULL);
if (ret != 0) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: avformat_open_input failed: %s", vc_errstr(ret)); return -1; }
if (avformat_find_stream_info(ctx->ifmt, NULL) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: avformat_find_stream_info failed: %s", ctx->src); return -1;
}
if (avformat_alloc_output_context2(&ctx->ofmt, NULL, "mp4", ctx->dst) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: alloc output context (mp4) failed: %s", ctx->dst); return -1;
}
if (vc_open_decoder(&ctx->vid, ctx->ifmt, AVMEDIA_TYPE_VIDEO) < 0) return -1;
if (vc_open_video(ctx) < 0) return -1;
if (vc_open_decoder(&ctx->aud, ctx->ifmt, AVMEDIA_TYPE_AUDIO) == 0)
vc_open_audio(ctx);
if (!(ctx->ofmt->oformat->flags & AVFMT_NOFILE)) {
ret = avio_open(&ctx->ofmt->pb, ctx->dst, AVIO_FLAG_WRITE);
if (ret < 0) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: avio_open failed: %s", vc_errstr(ret)); return -1; }
}
if (avformat_write_header(ctx->ofmt, NULL) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: avformat_write_header failed: %s", ctx->dst); return -1;
}
int64_t bytes_done = 0;
AVPacket* pkt = av_packet_alloc();
if (!pkt) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: av_packet_alloc failed"); return -1; }
while (1) {
ret = av_read_frame(ctx->ifmt, pkt);
if (ret == AVERROR_EOF) break;
if (ret < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: av_read_frame failed: %s", vc_errstr(ret));
av_packet_free(&pkt);
return -1;
}
if (ctx->src_size > 0) {
bytes_done += pkt->size;
int p = (int)(bytes_done * 1000 / ctx->src_size);
if (p > 1000) p = 1000;
vc_progress_set(p);
}
if (pkt->stream_index == ctx->vid.in_idx) {
int eof = 0;
if (vc_decode_send(ctx, &ctx->vid, pkt, &eof) < 0) { av_packet_free(&pkt); return -1; }
} else if (ctx->aud.in_idx >= 0 && pkt->stream_index == ctx->aud.in_idx && ctx->aud.enc_ctx) {
int eof = 0;
if (vc_decode_send(ctx, &ctx->aud, pkt, &eof) < 0) { av_packet_free(&pkt); return -1; }
}
av_packet_unref(pkt);
}
av_packet_free(&pkt);
/* флашим декодеры (досылаем NULL → выкачиваем оставшиеся кадры), затем энкодеры */
{ int eof = 0; vc_decode_send(ctx, &ctx->vid, NULL, &eof); }
if (ctx->aud.enc_ctx) { int eof = 0; vc_decode_send(ctx, &ctx->aud, NULL, &eof); }
vc_flush(ctx, &ctx->vid);
if (ctx->aud.enc_ctx) vc_flush(ctx, &ctx->aud);
if (av_write_trailer(ctx->ofmt) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: av_write_trailer failed: %s", ctx->dst); return -1;
}
vc_progress_set(1000);
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "video: wrote %lld bytes -> %s", (long long)bytes_done, ctx->dst);
return 0;
}
static void vc_cleanup(struct vc_ctx* ctx) {
vc_stream_close(&ctx->vid);
vc_stream_close(&ctx->aud);
if (ctx->ofmt) {
if (ctx->ofmt->pb) avio_closep(&ctx->ofmt->pb);
avformat_free_context(ctx->ofmt);
ctx->ofmt = NULL;
}
if (ctx->ifmt) { avformat_close_input(&ctx->ifmt); ctx->ifmt = NULL; }
}
static void vc_done_trampoline(void* raw) {
struct vc_ctx* ctx = (struct vc_ctx*)raw;
vc_active_set(0);
vc_progress_set(-1);
if (ctx->done) ctx->done(ctx->arg, ctx->err);
u_free(ctx);
}
static void* vc_thread_entry(void* raw) {
struct vc_ctx* ctx = (struct vc_ctx*)raw;
struct video_info info;
if (video_get_info(ctx->src, &info) != 0 || !info.is_video) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: not a video file: %s", ctx->src);
ctx->err = -1;
goto out;
}
if (!video_needs_transcode(&info)) {
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "video: no transcode needed (mp4/h264 %dx%d %dkbit <= limits), copy as-is: %s",
info.width, info.height, (int)(info.bitrate / 1000), ctx->src);
vc_progress_set(0);
ctx->err = vc_copy_file(ctx->src, ctx->dst) == 0 ? 0 : -2;
if (ctx->err) DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: copy failed %s -> %s", ctx->src, ctx->dst);
vc_progress_set(ctx->err ? -1 : 1000);
goto out;
}
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "video: transcode %s -> %s (src %dx%d %dkbit %s/%s, size=%lld)",
ctx->src, ctx->dst, info.width, info.height, (int)(info.bitrate / 1000),
info.codec, info.container, (long long)ctx->src_size);
vc_progress_set(0);
ctx->err = vc_run_transcode(ctx);
if (ctx->err) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: transcode failed %s -> %s", ctx->src, ctx->dst);
vc_progress_set(-1);
} else {
DEBUG_INFO(DEBUG_CATEGORY_VIDEO, "video: transcode done %s", ctx->dst);
}
vc_cleanup(ctx);
out:
uasync_post(ctx->ua, vc_done_trampoline, ctx);
return NULL;
}
int video_transcode_start(struct UASYNC* ua, const char* src, const char* dst,
void (*done)(void* arg, int err), void* arg) {
if (!ua || !src || !dst || !done) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: transcode_start invalid args");
return -1;
}
if (vc_active_get()) {
DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "video: transcode already running");
return -1;
}
struct vc_ctx* ctx = u_calloc(1, sizeof(*ctx));
if (!ctx) return -1;
snprintf(ctx->src, sizeof(ctx->src), "%s", src);
snprintf(ctx->dst, sizeof(ctx->dst), "%s", dst);
ctx->ua = ua;
ctx->done = done;
ctx->arg = arg;
ctx->aud.in_idx = -1;
struct stat st;
ctx->src_size = (stat(src, &st) == 0 && st.st_size > 0) ? (int64_t)st.st_size : 0;
vc_active_set(1);
vc_progress_set(0);
pthread_t tid;
int rc = pthread_create(&tid, NULL, vc_thread_entry, ctx);
if (rc != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "video: pthread_create failed rc=%d", rc);
vc_active_set(0);
vc_progress_set(-1);
u_free(ctx);
return -1;
}
pthread_detach(tid);
return 0;
}
#endif /* HAVE_FFMPEG */

69
src/video/video.h

@ -0,0 +1,69 @@
// video.h — пробинг и транскодирование видео (FFmpeg, опционально)
#ifndef VIDEO_H
#define VIDEO_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
struct UASYNC;
/* ── целевой профиль: H.264 в MP4, не более 720p / 2 Mbit ── */
#define VIDEO_TARGET_MAX_WIDTH 1280
#define VIDEO_TARGET_MAX_HEIGHT 720
#define VIDEO_TARGET_MAX_BITRATE 2000000 // 2 Mbit (только видео)
#define VIDEO_TARGET_AUDIO_BITRATE 128000 // AAC 128 kbit
/* ── информация о видеофайле ── */
struct video_info {
int is_video; // 1 = открыт и есть видеопоток
int width;
int height;
int64_t bitrate; // bitrate видеопотока (или контейнера)
int64_t duration_ms;
int has_audio;
char codec[32]; // напр. "h264", "hevc", "mpeg4"
char container[32]; // короткое имя контейнера (ifmt->name)
};
/*
* Быстрый пробинг (читает только заголовок). Вызывается из любого потока
* (в т.ч. из GUI-потока Qt) — thread-safe, не блокирует надолго.
* Возвращает 1 если файл содержит ffmpeg-совместимый видеопоток, иначе 0.
*/
int video_probe(const char* path);
/* Полное чтение метаданных. Заполняет info. Возвращает 0 при успехе. */
int video_get_info(const char* path, struct video_info* out);
/*
* Решает, нужен ли транскод: 0 = уже подходит (слать как есть),
* 1 = нужен транскод. Вызывается из любого потока.
*/
int video_needs_transcode(const struct video_info* info);
/*
* Запуск транскодирования. Вызывается из uasync-потока.
* Создаёт отдельный pthread, который либо перекодирует src->dst в целевой
* профиль, либо (если транскод не нужен) копирует src->dst как есть.
* Прогресс обновляется атомарно, читается через video_transcode_get_progress().
* По завершении done(arg, err) выполняется в uasync-потоке (через uasync_post).
*/
int video_transcode_start(struct UASYNC* ua,
const char* src, const char* dst,
void (*done)(void* arg, int err), void* arg);
/* Прогресс активного транскода в промилле 0..1000, -1 если транскода нет. */
int video_transcode_get_progress(void);
/* 1 = транскод выполняется прямо сейчас. */
int video_transcode_is_active(void);
#ifdef __cplusplus
}
#endif
#endif /* VIDEO_H */

2
tests/Makefile.am

@ -102,7 +102,7 @@ endif
CRYPTO_LIBS = -lcrypto CRYPTO_LIBS = -lcrypto
# Common libraries (lib/dns.c собирается с -DDNS_RANDOM=RAND_bytes → нужен -lcrypto) # Common libraries (lib/dns.c собирается с -DDNS_RANDOM=RAND_bytes → нужен -lcrypto)
COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) COMMON_LIBS = $(top_builddir)/lib/libuasync.a $(CRYPTO_LIBS) -lpthread $(WIN_LIBS) @FFMPEG_LIBS@
# Test definitions # Test definitions
test_etcp_bbr_SOURCES = test_etcp_bbr.c test_etcp_bbr_SOURCES = test_etcp_bbr.c

13
tools/chatcli

@ -14,6 +14,7 @@
# members <ch_id> list members with full state # members <ch_id> list members with full state
# messages <ch_id> [count] read last messages # messages <ch_id> [count] read last messages
# send <ch_id> <text> send text message # send <ch_id> <text> send text message
# attach <ch_id> <path> [--video] send a file (or video) as attachment
# invite <ch_id> [node_id] create invite link (auto best node by default) # invite <ch_id> [node_id] create invite link (auto best node by default)
# invite_nodes <ch_id> list reachable nodes for invite # invite_nodes <ch_id> list reachable nodes for invite
# connect <utun://...> join channel via invite link # connect <utun://...> join channel via invite link
@ -98,6 +99,12 @@ def cmd_messages(ch_id, count=None):
def cmd_send(ch_id, text): def cmd_send(ch_id, text):
_req("send", ch=ch_id, content_type="text", data=text) _req("send", ch=ch_id, content_type="text", data=text)
def cmd_attach(ch_id, path, as_video=False, content_type=None):
params = {"ch": ch_id, "path": path, "as_video": 1 if as_video else 0}
if content_type:
params["content_type"] = content_type
_req("attach", **params)
def cmd_invite(ch_id, node_id=None): def cmd_invite(ch_id, node_id=None):
params = {"ch": ch_id} params = {"ch": ch_id}
if node_id: if node_id:
@ -173,6 +180,12 @@ def main():
elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members <ch_id>"),)) elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members <ch_id>"),))
elif cmd in ("messages","msgs"): cmd_messages(*args[1:3] if len(args) > 1 else (_die("usage: messages <ch_id> [count]"),)) elif cmd in ("messages","msgs"): cmd_messages(*args[1:3] if len(args) > 1 else (_die("usage: messages <ch_id> [count]"),))
elif cmd == "send": cmd_send(*args[1:3] if len(args) > 2 else (_die("usage: send <ch_id> <text>"),)) elif cmd == "send": cmd_send(*args[1:3] if len(args) > 2 else (_die("usage: send <ch_id> <text>"),))
elif cmd == "attach":
if len(args) < 3:
_die("usage: attach <ch_id> <path> [--video]")
as_video = "--video" in args
path = args[2]
cmd_attach(args[1], path, as_video)
elif cmd == "invite": cmd_invite(*args[1:3] if len(args) > 1 else (_die("usage: invite <ch_id> [node_id]"),)) elif cmd == "invite": cmd_invite(*args[1:3] if len(args) > 1 else (_die("usage: invite <ch_id> [node_id]"),))
elif cmd == "invite_nodes": cmd_invite_nodes(*args[1:2] if len(args) > 1 else (_die("usage: invite_nodes <ch_id>"),)) elif cmd == "invite_nodes": cmd_invite_nodes(*args[1:2] if len(args) > 1 else (_die("usage: invite_nodes <ch_id>"),))
elif cmd in ("connect","join"): cmd_connect(*args[1:2] if len(args) > 1 else (_die("usage: connect <utun://...>"),)) elif cmd in ("connect","join"): cmd_connect(*args[1:2] if len(args) > 1 else (_die("usage: connect <utun://...>"),))

8
tools/chatcli_commands.txt

@ -49,6 +49,14 @@ send <ch_id> <text>
Отправить текстовое сообщение в канал. Отправить текстовое сообщение в канал.
Пример: chatcli send 12345 "hello world" Пример: chatcli send 12345 "hello world"
attach <ch_id> <path> [--video]
Прикрепить файл к сообщению. Файл копируется в медиа-каталог канала.
С флагом --video — файл обрабатывается как видео: если ffmpeg доступен,
перекодируется в H.264/MP4 (720p, ≤2 Мбит); если уже в нужном формате —
отправляется как есть.
Пример: chatcli attach 12345 /tmp/photo.jpg
chatcli attach 12345 /tmp/clip.mov --video
invite <ch_id> [node_id] invite <ch_id> [node_id]
Создать invite-ссылку для канала (формат utun://...). Создать invite-ссылку для канала (формат utun://...).
Без node_id — автоматический выбор лучшего достижимого узла. Без node_id — автоматический выбор лучшего достижимого узла.

20
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt

@ -14,6 +14,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -171,6 +174,19 @@ private fun MemberListItem(
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.isSuper) {
Icon(Icons.Filled.Bolt, "Supernode", tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (member.isAdmin) {
Icon(Icons.Filled.Star, "Admin", tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (member.isModer) {
Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF2196F3), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text( Text(
text = member.name, text = member.name,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@ -178,8 +194,10 @@ private fun MemberListItem(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None, textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified,
modifier = Modifier.weight(1f)
) )
}
val parts = mutableListOf<String>() val parts = mutableListOf<String>()
if (member.isAdmin) parts.add("Admin") if (member.isAdmin) parts.add("Admin")
if (member.isSuper) parts.add("Super") if (member.isSuper) parts.add("Super")

13
tools/chatgui/build_nix.sh

@ -55,12 +55,21 @@ install_deps() {
echo "[deps] installing build dependencies..." echo "[deps] installing build dependencies..."
if [ "$OS_ID" = linux ]; then if [ "$OS_ID" = linux ]; then
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential cmake qt6-base-dev librlottie-dev zlib1g-dev libssl-dev sudo apt-get install -y build-essential cmake qt6-base-dev librlottie-dev zlib1g-dev libssl-dev \
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev
else else
sudo pkg install -y cmake qt6-base rlottie openssl sudo pkg install -y cmake qt6-base rlottie openssl ffmpeg
fi fi
} }
# Auto-detect FFmpeg (optional video transcoding). CMake finds it via pkg-config.
if command -v pkg-config >/dev/null 2>&1 && \
pkg-config --exists libavformat libavcodec libavutil libswscale libswresample 2>/dev/null; then
echo "[ffmpeg] detected — video transcoding enabled"
else
echo "[ffmpeg] not found — video transcoding disabled"
fi
SAN_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" SAN_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g"
if [ "$ASAN" -eq 1 ]; then if [ "$ASAN" -eq 1 ]; then
echo "[build] ASAN ENABLED" echo "[build] ASAN ENABLED"

7
tools/chatgui/libutun/CMakeLists.txt

@ -108,6 +108,13 @@ target_compile_definitions(utun PRIVATE
USE_SQLITE USE_SQLITE
) )
# FFmpeg (optional) — FFMPEG_* переменные вычислены в родительском CMakeLists.txt
if(FFMPEG_FOUND)
target_include_directories(utun PUBLIC ${FFMPEG_INCLUDE_DIRS})
target_compile_definitions(utun PRIVATE HAVE_FFMPEG)
target_link_libraries(utun PUBLIC ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
endif()
set_target_properties(utun PROPERTIES set_target_properties(utun PROPERTIES
C_STANDARD 99 C_STANDARD 99
C_STANDARD_REQUIRED ON C_STANDARD_REQUIRED ON

67
tools/chatgui/src/inputbar.cpp

@ -10,6 +10,7 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QFileDialog> #include <QFileDialog>
#include <QMessageBox>
#include <QKeyEvent> #include <QKeyEvent>
#include <QFontMetrics> #include <QFontMetrics>
#include <QScrollBar> #include <QScrollBar>
@ -19,8 +20,14 @@
#include <QDir> #include <QDir>
#include <QProgressBar> #include <QProgressBar>
#include <QIcon> #include <QIcon>
#include <QTimer>
#include <QFileInfo>
#include <cmath> #include <cmath>
extern "C" {
#include "video/video.h"
}
static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) { static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) {
auto *btn = new QPushButton(parent); auto *btn = new QPushButton(parent);
btn->setIcon(icon); btn->setIcon(icon);
@ -85,6 +92,10 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
m_attachPreview->hide(); m_attachPreview->hide();
mainLayout->addWidget(m_attachPreview); mainLayout->addWidget(m_attachPreview);
m_videoProgressTimer = new QTimer(this);
m_videoProgressTimer->setInterval(150);
connect(m_videoProgressTimer, &QTimer::timeout, this, &InputBar::onVideoProgressTick);
auto *layout = new QHBoxLayout(); auto *layout = new QHBoxLayout();
layout->setSpacing(4); layout->setSpacing(4);
@ -225,13 +236,22 @@ void InputBar::onSendClicked() {
if (!hasAttach && text.isEmpty()) return; if (!hasAttach && text.isEmpty()) return;
if (hasAttach) { if (hasAttach) {
if (m_pendingAttachAsVideo) {
GUI_INFO("send: video=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName));
emit sendVideoAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName);
m_attachPreviewLabel->setText(QString::fromUtf8("\xF0\x9F\x8E\xAC Перекодирование… 0%"));
m_attachPreview->show();
m_videoProgressTimer->start();
} else {
GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName));
emit sendFileAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); emit sendFileAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName);
m_attachPreview->hide();
}
m_pendingAttachSrc.clear(); m_pendingAttachSrc.clear();
m_pendingAttachDest.clear(); m_pendingAttachDest.clear();
m_pendingAttachName.clear(); m_pendingAttachName.clear();
m_pendingAttachSize = 0; m_pendingAttachSize = 0;
m_attachPreview->hide(); m_pendingAttachAsVideo = false;
} }
if (!text.isEmpty()) { if (!text.isEmpty()) {
emit sendMessage(text); emit sendMessage(text);
@ -326,21 +346,46 @@ void InputBar::onAttachClicked() {
QDateTime now = QDateTime::currentDateTime(); QDateTime now = QDateTime::currentDateTime();
QString ts = now.toString("yyyyMMdd-HHmmss"); QString ts = now.toString("yyyyMMdd-HHmmss");
int rnd = rand() & 0xFFFF; int rnd = rand() & 0xFFFF;
QString destPath = QString("%1/%2_%3_%4.%5")
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0')).arg(fi.suffix().isEmpty() ? "bin" : fi.suffix()); bool asVideo = false;
if (video_probe(srcPath.toUtf8().constData())) {
QMessageBox box(this);
box.setWindowTitle(QString::fromUtf8("Отправить видео"));
box.setText(QString::fromUtf8("Файл содержит видео. Как отправить?"));
QPushButton *fileBtn = box.addButton(QString::fromUtf8("Как файл"), QMessageBox::AcceptRole);
QPushButton *videoBtn = box.addButton(QString::fromUtf8("Как видео"), QMessageBox::DestructiveRole);
box.exec();
asVideo = (box.clickedButton() == videoBtn);
if (!box.clickedButton() || (!asVideo && box.clickedButton() != fileBtn)) {
return; /* закрыт без выбора */
}
GUI_INFO("InputBar::onAttachClicked: video detected, mode=%s",
asVideo ? "video" : "file");
}
QString destPath;
if (asVideo) {
destPath = QString("%1/%2_%3_%4.mp4")
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0'));
} else {
destPath = QString("%1/%2_%3_%4.%5")
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0'))
.arg(fi.suffix().isEmpty() ? "bin" : fi.suffix());
}
m_pendingAttachSrc = srcPath; m_pendingAttachSrc = srcPath;
m_pendingAttachDest = destPath; m_pendingAttachDest = destPath;
m_pendingAttachName = fi.fileName(); m_pendingAttachName = fi.fileName();
m_pendingAttachSize = fi.size(); m_pendingAttachSize = fi.size();
m_pendingAttachAsVideo = asVideo;
QString labelText = QString::fromUtf8("\xF0\x9F\x93\x8E %1 (%2)") QString labelText = QString::fromUtf8("\xF0\x9F\x93\x8E %1 (%2)")
.arg(fi.fileName(), formatFileSize(fi.size())); .arg(fi.fileName(), formatFileSize(fi.size()));
m_attachPreviewLabel->setText(labelText); m_attachPreviewLabel->setText(labelText);
m_attachPreview->show(); m_attachPreview->show();
GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld", GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld video=%d",
qPrintable(srcPath), (long long)fi.size()); qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0);
} }
void InputBar::clearPendingAttachment() { void InputBar::clearPendingAttachment() {
@ -348,9 +393,21 @@ void InputBar::clearPendingAttachment() {
m_pendingAttachDest.clear(); m_pendingAttachDest.clear();
m_pendingAttachName.clear(); m_pendingAttachName.clear();
m_pendingAttachSize = 0; m_pendingAttachSize = 0;
m_pendingAttachAsVideo = false;
m_videoProgressTimer->stop();
m_attachPreview->hide(); m_attachPreview->hide();
} }
void InputBar::onVideoProgressTick() {
int p = video_transcode_get_progress();
if (p < 0) {
m_videoProgressTimer->stop();
m_attachPreview->hide();
return;
}
m_attachPreviewLabel->setText(QString::fromUtf8("\xF0\x9F\x8E\xAC Перекодирование… %1%").arg(p / 10));
}
void InputBar::resizeEvent(QResizeEvent* event) { void InputBar::resizeEvent(QResizeEvent* event) {
QWidget::resizeEvent(event); QWidget::resizeEvent(event);
if (m_recordOverlay) { if (m_recordOverlay) {

6
tools/chatgui/src/inputbar.h

@ -6,6 +6,7 @@ class QTextEdit;
class QPushButton; class QPushButton;
class QLabel; class QLabel;
class QProgressBar; class QProgressBar;
class QTimer;
class EmojiPanel; class EmojiPanel;
class AudioRecorder; class AudioRecorder;
@ -23,6 +24,8 @@ signals:
int durationMs, const QString& waveformStr); int durationMs, const QString& waveformStr);
void sendFileAttachment(const QString& mediaSrc, const QString& mediaDest, void sendFileAttachment(const QString& mediaSrc, const QString& mediaDest,
const QString& displayName); const QString& displayName);
void sendVideoAttachment(const QString& mediaSrc, const QString& mediaDest,
const QString& displayName);
protected: protected:
bool eventFilter(QObject *obj, QEvent *event) override; bool eventFilter(QObject *obj, QEvent *event) override;
@ -35,6 +38,7 @@ private slots:
void onPttReleased(); void onPttReleased();
void onAttachClicked(); void onAttachClicked();
void clearPendingAttachment(); void clearPendingAttachment();
void onVideoProgressTick();
void updateRecordingUI(int ms); void updateRecordingUI(int ms);
void showRecordOverlay(); void showRecordOverlay();
void hideRecordOverlay(); void hideRecordOverlay();
@ -63,4 +67,6 @@ private:
QString m_pendingAttachDest; QString m_pendingAttachDest;
QString m_pendingAttachName; QString m_pendingAttachName;
qint64 m_pendingAttachSize = 0; qint64 m_pendingAttachSize = 0;
bool m_pendingAttachAsVideo = false;
QTimer *m_videoProgressTimer = nullptr;
}; };

24
tools/chatgui/src/messagelist.cpp

@ -331,6 +331,30 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req); gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req);
}); });
connect(m_inputBar, &InputBar::sendVideoAttachment, this, [this](const QString& mediaSrc, const QString& mediaDest,
const QString& displayName) {
if (m_currentChannelId.isEmpty()) return;
QByteArray textData = displayName.toUtf8();
auto ts = QDateTime::currentMSecsSinceEpoch();
struct chat_msg_submit* req = (struct chat_msg_submit*)
u_malloc(sizeof(struct chat_msg_submit) + textData.size());
memset(req, 0, sizeof(*req));
snprintf(req->channel_id, sizeof(req->channel_id), "%s",
m_currentChannelId.toUtf8().constData());
snprintf(req->content_type, sizeof(req->content_type), "video/mp4");
snprintf(req->media_src, sizeof(req->media_src), "%s", mediaSrc.toUtf8().constData());
snprintf(req->media_dest, sizeof(req->media_dest), "%s", mediaDest.toUtf8().constData());
req->media_copy = 0;
req->media_video = 1;
req->data = (uint8_t*)(req + 1);
req->data_len = (uint32_t)textData.size();
memcpy(req->data, textData.constData(), textData.size());
req->timestamp = (uint64_t)ts;
gui_bridge_post_uasync_fn(chat_core_submit_trampoline, req);
});
connect(AnimTimer::instance(), &AnimTimer::ticked, this, [this]() { connect(AnimTimer::instance(), &AnimTimer::ticked, this, [this]() {
m_view->viewport()->update(); m_view->viewport()->update();
}); });

Loading…
Cancel
Save