From 12a43ad8e76fbe454a4418f3f75bbec90ea113dc Mon Sep 17 00:00:00 2001 From: MAULIK MK <214461757+maulik-mk@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:32:25 +0530 Subject: [PATCH 1/5] chore: Add initial project setup with package.json and update .gitignore (#1) --- .gitignore | 4 ++++ package.json | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 package.json diff --git a/.gitignore b/.gitignore index 9a5aced..c0e2b02 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,7 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* + +.DS_Store +video +output \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a196956 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "worker", + "version": "0.0.0", + "description": "FFmpeg worker", + "repository": { + "type": "git", + "url": "git+https://github.com/maulik-mk/mp.ii-worker.git" + }, + "keywords": [ + "ffmpeg", + "worker", + "video processing", + "video encoding", + "video decoding", + "video transcoding", + "video streaming", + "video processing worker", + "video processing worker", + "nodjs" + ], + "author": "Maulik MK", + "license": "ISC", + "bugs": { + "url": "https://github.com/maulik-mk/mp.ii-worker/issues" + }, + "homepage": "https://github.com/maulik-mk/mp.ii-worker#readme" +} From 056690ab218283a8715a1c9b5cb0b69605d93a54 Mon Sep 17 00:00:00 2001 From: MAULIK MK <214461757+maulik-mk@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:01:06 +0530 Subject: [PATCH 2/5] feat: Implement CI pipeline for building and testing the FFmpeg Docker image (#2) --- .github/workflows/ci.yml | 67 +++++++++++ Dockerfile | 130 ++++++++++++++++++++++ test/ffmpeg.test.sh | 232 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 test/ffmpeg.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7c64a8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [ "main", "master"] + pull_request: + branches: [ "main", "master"] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & Test (Ubuntu) + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + # 1. Checkout the repository code + - name: Checkout Code + uses: actions/checkout@v4 + + # 2. Log in to GitHub Container Registry + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_TOKEN }} + + # 3. Download a sample test video (1080p, ~2MB) + # Dynamically to keep the git repo small. + # The script expects 'video', so we simulate that structure. + - name: Download Test Asset + run: | + mkdir -p video + echo "Downloading sample video..." + # Using a reliable test video source (Big Buck Bunny stable link) + curl -L -o video/test.mp4 https://test-videos.co.uk/vids/bigbuckbunny/mp4/av1/1080/Big_Buck_Bunny_1080_10s_5MB.mp4 + ls -lh video/ + + # 4. Run the Test Script + # Since ffmpeg.test.sh handles 'docker build', we just run it directly. + - name: Run Test Suite + run: | + chmod +x test/ffmpeg.test.sh + bash test/ffmpeg.test.sh + + # 5. Push Image to GHCR (Only on Main) + - name: Push to GHCR + if: github.ref == 'refs/heads/main' + run: | + IMAGE_ID=ghcr.io/${{ github.repository }} + # Change all uppercase to lowercase + IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') + + # Tag the image built by ffmpeg.test.sh with the proper GHCR name + docker tag worker-ffmpeg $IMAGE_ID:latest + docker tag worker-ffmpeg $IMAGE_ID:${{ github.sha }} + + echo "Pushing $IMAGE_ID:latest" + docker push $IMAGE_ID:latest + docker push $IMAGE_ID:${{ github.sha }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c98b2c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,130 @@ +# ── STAGE 1: BUILDER ───────────────────────────────────────────────────────── +# Alpine 3.21 chosen for minimal footprint (~5MB base). +# We compile from source to control exactly which libraries are linked. +# ───────────────────────────────────────────────────────────────────────────── +FROM alpine:3.21 AS builder + +# Link to GitHub Repository for Package Visibility +LABEL org.opencontainers.image.source=https://github.com/maulik-mk/mp.ii-worker + +# 1. Install Build Dependencies +# We only install what's strictly necessary for compilation. +# - build-base: GCC, Make, libc-dev (standard build toolchain) +# - pkgconf: Helper for library path resolution +# - nasm/yasm: Assemblers required for x264 SIMD optimizations (CRITICAL for perf) +# - x264-dev: H.264 video encoder headers +# - fdk-aac-dev: High-quality AAC audio encoder headers (better than native aac) +RUN apk add --no-cache \ + build-base \ + pkgconf \ + nasm \ + yasm \ + x264-dev \ + fdk-aac-dev + +# 2. Download FFmpeg Source +ARG FFMPEG_VERSION=7.1 +RUN wget -q https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2 && \ + tar xjf ffmpeg-${FFMPEG_VERSION}.tar.bz2 && \ + rm ffmpeg-${FFMPEG_VERSION}.tar.bz2 + +WORKDIR /ffmpeg-${FFMPEG_VERSION} + +# 3. Configure & Compile +# ONLY (H.264 + AAC). +# +# Flags explained: +# --enable-small: Optimize for size +# --disable-network: Attack surface reduction. +# --disable-autodetect: Deterministic build. +# --disable-*: We strip all GUI dependencies (SDL, X11, XCB) and hardware accelerators +# --extra-cflags: "-O2" for standard optimization. "-march=armv8-a" matches target arch. +RUN ./configure \ + --prefix=/usr/local \ + --enable-gpl \ + --enable-nonfree \ + --enable-small \ + \ + # ── Core Capabilities ── \ + --enable-libx264 \ + --enable-libfdk-aac \ + \ + # ── Bloat Removal Strategy ── \ + --disable-doc \ + --disable-debug \ + --disable-ffplay \ + --disable-network \ + --disable-autodetect \ + \ + # ── GUI & System Dependencies Strip ── \ + --disable-sdl2 \ + --disable-libxcb \ + --disable-libxcb-shm \ + --disable-libxcb-xfixes \ + --disable-libxcb-shape \ + --disable-xlib \ + \ + # ── Hardware Acceleration Strip (CPU-only target) ── \ + --disable-vaapi \ + --disable-vdpau \ + --disable-videotoolbox \ + --disable-audiotoolbox \ + --disable-cuda \ + --disable-cuvid \ + --disable-nvenc \ + --disable-nvdec \ + \ + # ── Device Strip ── \ + --disable-indevs \ + --disable-outdevs \ + \ + # ── Compiler Optimizations ── \ + --extra-cflags="-O2 -march=armv8-a" \ + \ + && make -j$(nproc) \ + && make install \ + # Binary Stripping: Removes debug symbols (~80% size reduction on binary) + && strip /usr/local/bin/ffmpeg /usr/local/bin/ffprobe + +# ── STAGE 2: RUNTIME ───────────────────────────────────────────────────────── +FROM alpine:3.21 + +# 1. Install Runtime Dependencies +# These are the shared libraries our compiled FFmpeg binary links against. +# Without these, the binary will fail with "not found" errors. +# - x264-libs: H.264 runtime +# - fdk-aac: AAC runtime +# - ca-certificates: Required we ever need to fetch HTTPS or HTTP +# clean apk cache immediately to keep layer size minimal. +RUN apk add --no-cache \ + x264-libs \ + fdk-aac \ + ca-certificates \ + && rm -rf /var/cache/apk/* + +# 2. Copy Artifacts +# Bringing over ONLY the compiled binaries from Stage 1. +COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg +COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe + +# 3. Security Hardening +# - Create specific directories for input/output to control scope. +# - Create a non-root 'ffmpeg' user/group. +# - Chown directories to this user. +# - Switch USER context. +# Ideally, we should run with read-only root filesystem if possible. +RUN mkdir -p /input /output && \ + addgroup -S ffmpeg && adduser -S ffmpeg -G ffmpeg && \ + chown -R ffmpeg:ffmpeg /input /output + +USER ffmpeg +WORKDIR /work + +# 4. Verification Step +# Fails the build immediately if the binary is broken/missing libs. +RUN ffmpeg -version && ffprobe -version + +# Entrypoint configuration +# Allows passing arguments directly to docker run, e.g., "docker run img -i ..." +ENTRYPOINT ["ffmpeg"] +CMD ["-version"] diff --git a/test/ffmpeg.test.sh b/test/ffmpeg.test.sh new file mode 100644 index 0000000..68d06b6 --- /dev/null +++ b/test/ffmpeg.test.sh @@ -0,0 +1,232 @@ +#!/bin/bash + +# Enable strict mode: +# -e: Exit immediately if a command exits with a non-zero status. +# -u: Treat unset variables as an error when substituting. +# -o pipefail: The return value of a pipeline is the status of the last command to exit with a non-zero status. +set -euo pipefail + +# ------------------------------------------------------------------------------ +# Configuration +# ------------------------------------------------------------------------------ +IMAGE_NAME="worker-ffmpeg" + +# Resource limits for testing +CONTAINER_MEM_LIMIT="300m" +CONTAINER_CPU_LIMIT="1" + +# Paths +# script is run from the project root. +VIDEO_DIR="video" +OUTPUT_DIR="output" +TEST_VIDEO_FILE=$(ls "$VIDEO_DIR" | head -n 1) + +# colors. +BOLD="\033[1m" +GREEN="\033[32m" +CYAN="\033[36m" +YELLOW="\033[33m" +RED="\033[31m" +RESET="\033[0m" + +log_info() { + echo -e "${CYAN}[INFO]${RESET} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${RESET} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${RESET} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${RESET} $1" >&2 +} + +log_header() { + echo -e "\n${BOLD}=== $1 ===${RESET}" +} + +# ------------------------------------------------------------------------------ +# Steps +# ------------------------------------------------------------------------------ + +# Ensure Docker is installed and running +check_prerequisites() { + if ! command -v docker &> /dev/null; then + log_error "Docker is not installed or not in PATH." + exit 1 + fi +} + +# Step 1: Build the Docker image +build_image() { + log_header "Step 1: Building Docker Image" + log_info "Initiating build for image '${IMAGE_NAME}'..." + docker build -t "${IMAGE_NAME}" . + log_success "Docker image build completed successfully." +} + +# Step 2: Verify FFmpeg version +verify_ffmpeg_version() { + log_header "Step 2: Verifying FFmpeg Version" + local output + output=$(docker run --rm "${IMAGE_NAME}" -version) + echo "$output" | head -n 1 +} + +# Step 3: Verify FFprobe version +verify_ffprobe_version() { + log_header "Step 3: Verifying FFprobe Version" + local output + output=$(docker run --rm --entrypoint ffprobe "${IMAGE_NAME}" -version) + echo "$output" | head -n 1 +} + +# Step 4: Verify supported codecs +verify_codecs() { + log_header "Step 4: Validating Supported Codecs" + log_info "Checking for required libraries: libx264 (H.264) and libfdk_aac (AAC)..." + + local codecs + if codecs=$(docker run --rm "${IMAGE_NAME}" -codecs 2>/dev/null | grep -E "libx264|libfdk_aac"); then + echo "$codecs" + log_success "Required codecs verified successfully." + else + log_error "Critical Error: Required codecs (libx264, libfdk_aac) are missing from the image." + exit 1 + fi +} + +# Step 5: Transcode Test +run_transcode_test() { + log_header "Step 5: Transcoding Test" + local input_path="${VIDEO_DIR}/${TEST_VIDEO_FILE}" + local output_file="${OUTPUT_DIR}/test_720p.mp4" + + if [[ ! -f "$input_path" ]]; then + log_warn "Input file not found at '${input_path}'. Skipping transcoding test." + return + fi + + # Prepare output directory + mkdir -p "${OUTPUT_DIR}" + rm -f "${output_file}" + + log_info "Starting transcoding process (Memory Limit: ${CONTAINER_MEM_LIMIT})..." + + # Run FFmpeg in Docker + docker run --rm \ + --memory="${CONTAINER_MEM_LIMIT}" \ + --cpus="${CONTAINER_CPU_LIMIT}" \ + -v "$(pwd)/${VIDEO_DIR}:/input:ro" \ + -v "$(pwd)/${OUTPUT_DIR}:/output" \ + "${IMAGE_NAME}" \ + -y \ + -hide_banner -loglevel error \ + -stats \ + -i "/input/${TEST_VIDEO_FILE}" \ + -threads 1 \ + -filter_threads 1 \ + -vf "scale=1280:720" \ + -c:v libx264 -preset ultrafast -b:v 2800k \ + # First 10 seconds of the video only + -t 10 \ + -movflags +faststart \ + "/output/$(basename "${output_file}")" + + if [[ -f "${output_file}" ]]; then + log_success "Transcoding complete. Output file created at: ${output_file}" + ls -lh "${output_file}" + else + log_error "Transcoding failed. No output file was generated." + exit 1 + fi +} + +# Step 6: HLS Adaptive Bitrate Test +run_hls_test() { + log_header "Step 6: HLS Adaptive Bitrate Test" + local input_path="${VIDEO_DIR}/${TEST_VIDEO_FILE}" + + if [[ ! -f "$input_path" ]]; then + log_warn "Input file not found. Skipping HLS test." + return + fi + + local hls_root="${OUTPUT_DIR}/hls" + rm -rf "${hls_root}" + mkdir -p "${hls_root}"/{360p,720p,1080p} + + # Define common options to reduce repetition + local docker_opts="--rm --memory=${CONTAINER_MEM_LIMIT} --cpus=${CONTAINER_CPU_LIMIT} -v $(pwd)/${VIDEO_DIR}:/input:ro -v $(pwd)/${OUTPUT_DIR}/hls:/output" + local ffmpeg_opts="-hide_banner -loglevel error -stats -threads 1 -filter_threads 1" + local hls_flags="-f hls -hls_time 4 -hls_playlist_type vod -hls_list_size 0" + + # Pass 1: 360p + log_info "Generating 360p HLS stream segment..." + docker run ${docker_opts} "${IMAGE_NAME}" ${ffmpeg_opts} \ + -i "/input/${TEST_VIDEO_FILE}" -t 10 \ + -vf "scale=640:360" -c:v libx264 -preset ultrafast -b:v 800k \ + ${hls_flags} -hls_segment_filename '/output/360p/segment_%03d.ts' -y '/output/360p/stream.m3u8' + + # Pass 2: 720p + log_info "Generating 720p HLS stream segment..." + docker run ${docker_opts} "${IMAGE_NAME}" ${ffmpeg_opts} \ + -i "/input/${TEST_VIDEO_FILE}" -t 10 \ + -vf "scale=1280:720" -c:v libx264 -preset ultrafast -b:v 2800k \ + ${hls_flags} -hls_segment_filename '/output/720p/segment_%03d.ts' -y '/output/720p/stream.m3u8' + + # Pass 3: 1080p + log_info "Generating 1080p HLS stream segment..." + docker run ${docker_opts} "${IMAGE_NAME}" ${ffmpeg_opts} \ + -i "/input/${TEST_VIDEO_FILE}" -t 10 \ + -vf "scale=1920:1080" -c:v libx264 -preset ultrafast -b:v 5000k \ + ${hls_flags} -hls_segment_filename '/output/1080p/segment_%03d.ts' -y '/output/1080p/stream.m3u8' + + # Generate Master Playlist + cat > "${hls_root}/master.m3u8" < Date: Tue, 17 Feb 2026 19:43:13 +0530 Subject: [PATCH 3/5] fix: Remove architecture-specific optimization flag from FFmpeg build (#4) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c98b2c0..2aa6c16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -79,7 +79,7 @@ RUN ./configure \ --disable-outdevs \ \ # ── Compiler Optimizations ── \ - --extra-cflags="-O2 -march=armv8-a" \ + --extra-cflags="-O2" \ \ && make -j$(nproc) \ && make install \ From 342927b1aac794226414374b119b1a1aded144de Mon Sep 17 00:00:00 2001 From: MAULIK MK <214461757+maulik-mk@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:03:47 +0530 Subject: [PATCH 4/5] fix: ensure ffmpeg test script always specifies output file (#5) --- test/ffmpeg.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ffmpeg.test.sh b/test/ffmpeg.test.sh index 68d06b6..e5fcd27 100644 --- a/test/ffmpeg.test.sh +++ b/test/ffmpeg.test.sh @@ -118,6 +118,7 @@ run_transcode_test() { log_info "Starting transcoding process (Memory Limit: ${CONTAINER_MEM_LIMIT})..." # Run FFmpeg in Docker + # First 10 seconds of the video only docker run --rm \ --memory="${CONTAINER_MEM_LIMIT}" \ --cpus="${CONTAINER_CPU_LIMIT}" \ @@ -132,7 +133,6 @@ run_transcode_test() { -filter_threads 1 \ -vf "scale=1280:720" \ -c:v libx264 -preset ultrafast -b:v 2800k \ - # First 10 seconds of the video only -t 10 \ -movflags +faststart \ "/output/$(basename "${output_file}")" From 39580d381847e5a7cc65b20df2b38c968605b017 Mon Sep 17 00:00:00 2001 From: MAULIK MK <214461757+maulik-mk@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:16:20 +0530 Subject: [PATCH 5/5] [fix] [CI] : Grant full permissions to the test output directory. (#7) - Enable CI for the dev branch. - permissions to the ffmpeg test output directory. - Update CI test video download to use H264 codec instead of AV1. - test: add 777 permissions to HLS output directory in ffmpeg test --- .github/workflows/ci.yml | 6 +++--- test/ffmpeg.test.sh | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7c64a8..bc917fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "main", "master"] + branches: [ "main", "master", 'dev'] pull_request: - branches: [ "main", "master"] + branches: [ "main", "master", 'dev'] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -40,7 +40,7 @@ jobs: mkdir -p video echo "Downloading sample video..." # Using a reliable test video source (Big Buck Bunny stable link) - curl -L -o video/test.mp4 https://test-videos.co.uk/vids/bigbuckbunny/mp4/av1/1080/Big_Buck_Bunny_1080_10s_5MB.mp4 + curl -L -o video/test.mp4 https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/1080/Big_Buck_Bunny_1080_10s_5MB.mp4 ls -lh video/ # 4. Run the Test Script diff --git a/test/ffmpeg.test.sh b/test/ffmpeg.test.sh index e5fcd27..df6c028 100644 --- a/test/ffmpeg.test.sh +++ b/test/ffmpeg.test.sh @@ -113,6 +113,7 @@ run_transcode_test() { # Prepare output directory mkdir -p "${OUTPUT_DIR}" + chmod 777 "${OUTPUT_DIR}" rm -f "${output_file}" log_info "Starting transcoding process (Memory Limit: ${CONTAINER_MEM_LIMIT})..." @@ -159,6 +160,7 @@ run_hls_test() { local hls_root="${OUTPUT_DIR}/hls" rm -rf "${hls_root}" mkdir -p "${hls_root}"/{360p,720p,1080p} + chmod -R 777 "${hls_root}" # Define common options to reduce repetition local docker_opts="--rm --memory=${CONTAINER_MEM_LIMIT} --cpus=${CONTAINER_CPU_LIMIT} -v $(pwd)/${VIDEO_DIR}:/input:ro -v $(pwd)/${OUTPUT_DIR}/hls:/output"