diff --git a/build.sh b/build.sh index e1bb0b52..cdc7e05d 100755 --- a/build.sh +++ b/build.sh @@ -137,16 +137,27 @@ else 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 if [ ! -f Makefile ]; then echo "" echo "Running ./configure..." 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 - ./configure --enable-hardening + ./configure --enable-hardening $FFMPEG_OPT else - ./configure + ./configure $FFMPEG_OPT fi fi diff --git a/configure.ac b/configure.ac index 47a84d08..f584d442 100644 --- a/configure.ac +++ b/configure.ac @@ -127,6 +127,44 @@ fi AM_CONDITIONAL([HAVE_WHISPER], [test "x$with_whisper" = "xyes"]) 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... 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 diff --git a/lib/debug_config.c b/lib/debug_config.c index 617ff3b4..cb024dc7 100644 --- a/lib/debug_config.c +++ b/lib/debug_config.c @@ -129,6 +129,7 @@ static const struct { {"chat_sync", DEBUG_CATEGORY_CHAT_SYNC}, {"member_sync", DEBUG_CATEGORY_MEMBER_SYNC}, {"proxy", DEBUG_CATEGORY_PROXY}, + {"video", DEBUG_CATEGORY_VIDEO}, {"all", DEBUG_CATEGORY_ALL}, {NULL, DEBUG_CATEGORY_NONE} }; diff --git a/lib/debug_config.h b/lib/debug_config.h index e0baa12d..2d77e864 100644 --- a/lib/debug_config.h +++ b/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_MEMBER_SYNC 27 // Member + address + merkle sync #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 /* Debug configuration structure */ diff --git a/src/Makefile.am b/src/Makefile.am index 8d2b5d93..8c9bac7e 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -56,6 +56,7 @@ utun_CORE_SOURCES = \ media_delivery/media_download.c \ media_delivery/media_index.c \ media_async/media_async.c \ + video/video.c \ transport_layer/dummynet.c \ ntp_time.c \ ntp_node_time.c \ @@ -141,6 +142,7 @@ libutun_a_SOURCES = \ media_delivery/media_download.c \ media_delivery/media_index.c \ media_async/media_async.c \ + video/video.c \ transport_layer/dummynet.c \ ntp_time.c \ ntp_node_time.c \ @@ -185,6 +187,7 @@ utun_CFLAGS = \ -I$(top_srcdir)/src/routing_layer \ -I$(top_srcdir)/src/media_delivery \ -I$(top_srcdir)/src/media_async \ + -I$(top_srcdir)/src/video \ -I$(top_srcdir)/src/uip \ -g \ -DUSE_SQLITE \ @@ -206,6 +209,11 @@ utun_CFLAGS += -DHAVE_WHISPER utun_LDADD += @WHISPER_LIBS@ endif +if HAVE_FFMPEG +utun_CFLAGS += -DHAVE_FFMPEG @FFMPEG_CFLAGS@ +utun_LDADD += @FFMPEG_LIBS@ +endif + # Copy binary to project root after building all-local: copy-to-root diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index f1286712..351cf563 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -32,6 +32,7 @@ struct chat_msg_submit { char media_src[1024]; char media_dest[1024]; uint8_t media_copy; + uint8_t media_video; /* 1 = подготовить как видео (пробинг+транскод) */ uint8_t* data; uint32_t data_len; uint64_t timestamp; @@ -41,6 +42,9 @@ struct chat_msg_submit { 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-операции (оставлены для интроспекции) ── */ uint32_t chat_core_count(const char* ch_id); diff --git a/src/chat/chat_headless_control.c b/src/chat/chat_headless_control.c index bb0aa6e9..0dd796b5 100644 --- a/src/chat/chat_headless_control.c +++ b/src/chat/chat_headless_control.c @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include "../../lib/platform_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); } +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) { 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; } @@ -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); -static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "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 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_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) { if (!cli || !json) return; diff --git a/src/chat/chat_msg.c b/src/chat/chat_msg.c index 677c5ee3..64ae53f6 100644 --- a/src/chat/chat_msg.c +++ b/src/chat/chat_msg.c @@ -13,6 +13,7 @@ #include "../transport_layer/secure_channel.h" #include "../media_delivery/media_index.h" #include "../media_delivery/media_download.h" +#include "../video/video.h" #include "../../lib/mem.h" #include "../../lib/platform_compat.h" @@ -22,7 +23,7 @@ chat_whisper_trigger_fn g_chat_whisper_trigger = NULL; /* 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, 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); } -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 (!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, - 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) { 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); } + 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( g_cc.inst->media_async, g_cc.inst->ua, g_cc.db, g_cc.my_node_id, g_cc.inst->my_ed25519_privkey, diff --git a/src/video/video.c b/src/video/video.c new file mode 100644 index 00000000..c136976c --- /dev/null +++ b/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 +#include +#include +#include +#include + +#ifdef HAVE_FFMPEG +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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 */ diff --git a/src/video/video.h b/src/video/video.h new file mode 100644 index 00000000..61f5c531 --- /dev/null +++ b/src/video/video.h @@ -0,0 +1,69 @@ +// video.h — пробинг и транскодирование видео (FFmpeg, опционально) +#ifndef VIDEO_H +#define VIDEO_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +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 */ diff --git a/tests/Makefile.am b/tests/Makefile.am index 6db0d0d7..7c3ff59b 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -102,7 +102,7 @@ endif CRYPTO_LIBS = -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_etcp_bbr_SOURCES = test_etcp_bbr.c diff --git a/tools/chatcli b/tools/chatcli index d67704c0..783f8917 100755 --- a/tools/chatcli +++ b/tools/chatcli @@ -14,6 +14,7 @@ # members list members with full state # messages [count] read last messages # send send text message +# attach [--video] send a file (or video) as attachment # invite [node_id] create invite link (auto best node by default) # invite_nodes list reachable nodes for invite # connect join channel via invite link @@ -98,6 +99,12 @@ def cmd_messages(ch_id, count=None): def cmd_send(ch_id, 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): params = {"ch": ch_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 "),)) elif cmd in ("messages","msgs"): cmd_messages(*args[1:3] if len(args) > 1 else (_die("usage: messages [count]"),)) elif cmd == "send": cmd_send(*args[1:3] if len(args) > 2 else (_die("usage: send "),)) + elif cmd == "attach": + if len(args) < 3: + _die("usage: attach [--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 [node_id]"),)) elif cmd == "invite_nodes": cmd_invite_nodes(*args[1:2] if len(args) > 1 else (_die("usage: invite_nodes "),)) elif cmd in ("connect","join"): cmd_connect(*args[1:2] if len(args) > 1 else (_die("usage: connect "),)) diff --git a/tools/chatcli_commands.txt b/tools/chatcli_commands.txt index 139a5220..1f9f80e1 100644 --- a/tools/chatcli_commands.txt +++ b/tools/chatcli_commands.txt @@ -49,6 +49,14 @@ send Отправить текстовое сообщение в канал. Пример: chatcli send 12345 "hello world" +attach [--video] + Прикрепить файл к сообщению. Файл копируется в медиа-каталог канала. + С флагом --video — файл обрабатывается как видео: если ffmpeg доступен, + перекодируется в H.264/MP4 (720p, ≤2 Мбит); если уже в нужном формате — + отправляется как есть. + Пример: chatcli attach 12345 /tmp/photo.jpg + chatcli attach 12345 /tmp/clip.mov --video + invite [node_id] Создать invite-ссылку для канала (формат utun://...). Без node_id — автоматический выбор лучшего достижимого узла. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index 69b48bd6..0de409d9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/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.material.icons.Icons 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.runtime.* import androidx.compose.ui.Alignment @@ -171,15 +174,30 @@ private fun MemberListItem( Spacer(Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = member.name, - fontWeight = FontWeight.Medium, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None, - color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified - ) + 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 = member.name, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None, + color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified, + modifier = Modifier.weight(1f) + ) + } val parts = mutableListOf() if (member.isAdmin) parts.add("Admin") if (member.isSuper) parts.add("Super") diff --git a/tools/chatgui/build_nix.sh b/tools/chatgui/build_nix.sh index 6d57d1eb..782e4454 100755 --- a/tools/chatgui/build_nix.sh +++ b/tools/chatgui/build_nix.sh @@ -55,12 +55,21 @@ install_deps() { echo "[deps] installing build dependencies..." if [ "$OS_ID" = linux ]; then 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 - sudo pkg install -y cmake qt6-base rlottie openssl + sudo pkg install -y cmake qt6-base rlottie openssl ffmpeg 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" if [ "$ASAN" -eq 1 ]; then echo "[build] ASAN ENABLED" diff --git a/tools/chatgui/libutun/CMakeLists.txt b/tools/chatgui/libutun/CMakeLists.txt index 4f01c8db..30b54f12 100644 --- a/tools/chatgui/libutun/CMakeLists.txt +++ b/tools/chatgui/libutun/CMakeLists.txt @@ -108,6 +108,13 @@ target_compile_definitions(utun PRIVATE 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 C_STANDARD 99 C_STANDARD_REQUIRED ON diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 5d05e34f..b26dc333 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -19,8 +20,14 @@ #include #include #include +#include +#include #include +extern "C" { +#include "video/video.h" +} + static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) { auto *btn = new QPushButton(parent); btn->setIcon(icon); @@ -85,6 +92,10 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par m_attachPreview->hide(); 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(); layout->setSpacing(4); @@ -225,13 +236,22 @@ void InputBar::onSendClicked() { if (!hasAttach && text.isEmpty()) return; if (hasAttach) { - GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); - emit sendFileAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); + 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)); + emit sendFileAttachment(m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); + m_attachPreview->hide(); + } m_pendingAttachSrc.clear(); m_pendingAttachDest.clear(); m_pendingAttachName.clear(); m_pendingAttachSize = 0; - m_attachPreview->hide(); + m_pendingAttachAsVideo = false; } if (!text.isEmpty()) { emit sendMessage(text); @@ -326,21 +346,46 @@ void InputBar::onAttachClicked() { QDateTime now = QDateTime::currentDateTime(); QString ts = now.toString("yyyyMMdd-HHmmss"); 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_pendingAttachDest = destPath; m_pendingAttachName = fi.fileName(); m_pendingAttachSize = fi.size(); + m_pendingAttachAsVideo = asVideo; QString labelText = QString::fromUtf8("\xF0\x9F\x93\x8E %1 (%2)") .arg(fi.fileName(), formatFileSize(fi.size())); m_attachPreviewLabel->setText(labelText); m_attachPreview->show(); - GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld", - qPrintable(srcPath), (long long)fi.size()); + GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld video=%d", + qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0); } void InputBar::clearPendingAttachment() { @@ -348,9 +393,21 @@ void InputBar::clearPendingAttachment() { m_pendingAttachDest.clear(); m_pendingAttachName.clear(); m_pendingAttachSize = 0; + m_pendingAttachAsVideo = false; + m_videoProgressTimer->stop(); 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) { QWidget::resizeEvent(event); if (m_recordOverlay) { diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index e18e18a4..e89c5fea 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -6,6 +6,7 @@ class QTextEdit; class QPushButton; class QLabel; class QProgressBar; +class QTimer; class EmojiPanel; class AudioRecorder; @@ -23,6 +24,8 @@ signals: int durationMs, const QString& waveformStr); void sendFileAttachment(const QString& mediaSrc, const QString& mediaDest, const QString& displayName); + void sendVideoAttachment(const QString& mediaSrc, const QString& mediaDest, + const QString& displayName); protected: bool eventFilter(QObject *obj, QEvent *event) override; @@ -35,6 +38,7 @@ private slots: void onPttReleased(); void onAttachClicked(); void clearPendingAttachment(); + void onVideoProgressTick(); void updateRecordingUI(int ms); void showRecordOverlay(); void hideRecordOverlay(); @@ -63,4 +67,6 @@ private: QString m_pendingAttachDest; QString m_pendingAttachName; qint64 m_pendingAttachSize = 0; + bool m_pendingAttachAsVideo = false; + QTimer *m_videoProgressTimer = nullptr; }; diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index dd7b8236..32d3563e 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/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); }); + 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]() { m_view->viewport()->update(); });