Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing Video Chunked Upload and HLS Transcoding with Spring Boot and FFmpeg on Windows

Tech 1

Core Concepts of Chunked Upload

Chunked upload involves dividing a file into smaller segments based on a defined rule, such as a fixed size like 20MB per chunk. Each file is assigned a unique identifier to distinguish its segmnets during upload. After all chunk are uploaded, the server validates and merges them to reconstruct the original file.

Benefits of Chunked Upload

This approach allows for resuming uploads from the point of failure in poor network conditions, avoiding the need to restart from the beginning. It also facilitates progress tracking and can improve upload reliability.

Principle of HLS Transcoding

Using FFmpeg, a video file is segmented into an M3U8 playlist and TS chunks. A Spring Boot application can process uploaded videos, perform transcoding, and return URLs for the M3U8 file and a poster image, enabling on line streaming.

Prerequisites: Installing FFmpeg on Windows

  1. Download and Extract: Obtain FFmpeg from http://ffmpeg.org/download.html and extract it to a local directory (e.g., D:\tools\ffmpeg\bin).
  2. Configure Environment Variable: Add the bin directory path to the system's PATH variable.
  3. Verify Installation: Open a command prompt and run ffmpeg -version. A successful installation will display version information.

Project Configuration

Maven Dependencies (pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.2</version>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>video-transcoder</artifactId>
    <version>1.0.0</version>
    <properties>
        <java.version>11</java.version>
        <javacv.version>1.5.4</javacv.version>
        <ffmpeg.version>4.3.1-1.5.4</ffmpeg.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.bytedeco</groupId>
            <artifactId>ffmpeg-platform</artifactId>
            <version>${ffmpeg.version}</version>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.11.0</version>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

Application Properties (application.yml)

server:
  port: 8080
app:
  video-storage: D:/video-storage/
spring:
  servlet:
    multipart:
      max-file-size: -1
      max-request-size: -1
  web:
    resources:
      static-locations:
        - "classpath:/static/"
        - "file:${app.video-storage}"

Utility Classes

Media Metadata Class

package com.example.util;
import com.google.gson.annotations.SerializedName;
import java.util.List;
public class VideoMetadata {
    public static class FormatInfo {
        @SerializedName("bit_rate")
        private String bitrate;
        public String getBitrate() { return bitrate; }
        public void setBitrate(String bitrate) { this.bitrate = bitrate; }
    }
    @SerializedName("format")
    private FormatInfo format;
    public FormatInfo getFormat() { return format; }
    public void setFormat(FormatInfo format) { this.format = format; }
}

Transcoding Configuration

package com.example.util;
import lombok.Data;
@Data
public class EncodingParams {
    private String snapshotTime = "00:00:00.001";
    private String segmentDuration = "10";
    private String trimStart;
    private String trimEnd;
}

FFmpeg Processing Utility

package com.example.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
public class VideoProcessor {
    private static final Logger log = LoggerFactory.getLogger(VideoProcessor.class);
    public static void convertToHLS(String inputPath, String outputDir, EncodingParams params)
            throws IOException, InterruptedException {
        if (!Files.exists(Paths.get(inputPath))) {
            throw new IllegalArgumentException("Source file not found: " + inputPath);
        }
        Path workDir = Paths.get(outputDir, "segments");
        Files.createDirectories(workDir);
        List<String> command = new ArrayList<>();
        command.add("ffmpeg");
        command.add("-i"); command.add(inputPath);
        command.add("-c:v"); command.add("libx264");
        command.add("-c:a"); command.add("copy");
        command.add("-hls_time"); command.add(params.getSegmentDuration());
        command.add("-hls_playlist_type"); command.add("vod");
        command.add("-hls_segment_filename"); command.add("%03d.ts");
        if (StringUtils.hasText(params.getTrimStart())) {
            command.add("-ss"); command.add(params.getTrimStart());
        }
        if (StringUtils.hasText(params.getTrimEnd())) {
            command.add("-to"); command.add(params.getTrimEnd());
        }
        command.add("playlist.m3u8");
        ProcessBuilder builder = new ProcessBuilder(command).directory(workDir.toFile());
        Process process = builder.start();
        readStream(process.getInputStream(), false);
        readStream(process.getErrorStream(), true);
        if (process.waitFor() != 0) {
            throw new RuntimeException("Transcoding failed");
        }
        captureThumbnail(inputPath, Paths.get(outputDir, "thumbnail.jpg").toString(),
                params.getSnapshotTime());
    }
    private static void readStream(java.io.InputStream stream, boolean isError) {
        new Thread(() -> {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    if (isError) log.error(line); else log.info(line);
                }
            } catch (IOException ignored) {}
        }).start();
    }
    private static boolean captureThumbnail(String videoPath, String imagePath, String timestamp)
            throws IOException, InterruptedException {
        List<String> cmd = List.of("ffmpeg", "-i", videoPath, "-ss", timestamp,
                "-vframes", "1", "-q:v", "2", imagePath);
        return new ProcessBuilder(cmd).start().waitFor() == 0;
    }
}

Controller Implementation

package com.example.controller;
import com.example.util.EncodingParams;
import com.example.util.VideoProcessor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@RestController
@RequestMapping("/api/video")
public class VideoUploadController {
    @Value("${app.video-storage}")
    private String storageRoot;
    @PostMapping("/upload")
    public Map<String, Object> handleUpload(@RequestPart("file") MultipartFile videoFile,
                                            @RequestPart("config") EncodingParams config) {
        log.info("Processing file: {}, size: {}", videoFile.getOriginalFilename(), videoFile.getSize());
        Path tempFile = null;
        try {
            String originalName = videoFile.getOriginalFilename();
            String baseName = originalName.substring(0, originalName.lastIndexOf('.'));
            String uniqueId = baseName + "-" + UUID.randomUUID().toString().replace("-", "");
            String dateFolder = DateTimeFormatter.ofPattern("yyyyMMdd").format(LocalDate.now());
            Path targetDir = Files.createDirectories(Paths.get(storageRoot, dateFolder, uniqueId));
            tempFile = Paths.get(System.getProperty("java.io.tmpdir"), originalName);
            videoFile.transferTo(tempFile);
            VideoProcessor.convertToHLS(tempFile.toString(), targetDir.toString(), config);
            Map<String, Object> response = new HashMap<>();
            response.put("status", "success");
            Map<String, String> data = new HashMap<>();
            data.put("playlist", String.join("/", "", dateFolder, uniqueId, "segments/playlist.m3u8"));
            data.put("thumbnail", String.join("/", "", dateFolder, uniqueId, "thumbnail.jpg"));
            response.put("data", data);
            return response;
        } catch (Exception e) {
            log.error("Upload processing error", e);
            Map<String, Object> error = new HashMap<>();
            error.put("status", "error");
            error.put("message", e.getMessage());
            return error;
        } finally {
            if (tempFile != null) {
                try { Files.deleteIfExists(tempFile); } catch (IOException ignored) {}
            }
        }
    }
}

Frontend Test Page (test.html)

<!DOCTYPE html>
<html>
<head>
    <title>Video Upload Test</title>
    <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body>
    <input type="file" accept="video/*" onchange="uploadVideo(this.files[0])">
    <video id="player" width="600" controls></video>
    <script>
        const player = document.getElementById('player');
        async function uploadVideo(file) {
            const config = {
                snapshotTime: "00:00:01.000",
                segmentDuration: "10",
                trimStart: "",
                trimEnd: ""
            };
            const formData = new FormData();
            formData.append("file", file);
            formData.append("config", new Blob([JSON.stringify(config)],
                {type: "application/json"}));
            try {
                const response = await fetch('/api/video/upload', {
                    method: 'POST',
                    body: formData
                });
                const result = await response.json();
                if (result.status === 'success') {
                    player.poster = result.data.thumbnail;
                    if (Hls.isSupported()) {
                        const hls = new Hls();
                        hls.loadSource(result.data.playlist);
                        hls.attachMedia(player);
                    } else if (player.canPlayType('application/vnd.apple.mpegurl')) {
                        player.src = result.data.playlist;
                    }
                } else {
                    alert('Processing failed: ' + result.message);
                }
            } catch (err) {
                console.error(err);
                alert('Upload error');
            }
        }
    </script>
</body>
</html>

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.