Implementing Video Chunked Upload and HLS Transcoding with Spring Boot and FFmpeg on Windows
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
- Download and Extract: Obtain FFmpeg from http://ffmpeg.org/download.html and extract it to a local directory (e.g.,
D:\tools\ffmpeg\bin). - Configure Environment Variable: Add the
bindirectory path to the system's PATH variable. - 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>