Xây Dựng Video Platform: Video Transcoding Pipeline

Video đã lên disk, nhưng chúng vẫn là raw MP4 file. Một video 1080p, 30 phút nặng khoảng 2GB — không thể serve thẳng cho user đang dùng 4G được. Trong bài này, chúng ta sẽ build transcoding pipeline chuyển đổi mỗi upload thành adaptive HLS stream ở nhiều resolution.
HLS (HTTP Live Streaming) chia video thành segment nhỏ (thường 10 giây mỗi segment) và tạo playlist cho phép player chuyển đổi quality level linh hoạt. Mạng chậm đang buffer? Chuyển sang 360p. WiFi nhanh? Nhảy lên 720p. Đó là adaptive bitrate streaming.
Thời gian: 3–4 giờ
Yêu cầu: Phase 5: Video Upload & Storage
Những gì chúng ta sẽ build trong bài này:
✅ FFmpeg HLS transcode sang 360p và 720p
✅ Async processing với Spring @Async và thread pool
✅ Progress tracking trong Redis với percentage update
✅ ffprobe metadata extraction (duration, resolution, codec)
✅ Tự động trigger transcode sau upload
✅ Admin UI cho transcoding status và progress
✅ Master playlist generation cho adaptive streaming
HLS Hoạt Động Thế Nào
Trước khi viết code, hãy hiểu chúng ta đang build gì:
Output cho mỗi lesson trông như thế này:
hls/
└── {courseId}/
└── {lessonId}/
├── master.m3u8 # Trỏ tới playlist từng quality
├── 360p/
│ ├── playlist.m3u8 # Liệt kê tất cả segment 360p
│ ├── segment-000.ts # Chunk video 10 giây
│ ├── segment-001.ts
│ └── ...
└── 720p/
├── playlist.m3u8
├── segment-000.ts
├── segment-001.ts
└── ...Master playlist (master.m3u8) cho player biết có những quality level nào. Player chọn một cái dựa trên tình trạng mạng rồi bắt đầu download segment.
Setup FFmpeg
Thêm FFmpeg vào Docker
# api/Dockerfile
FROM eclipse-temurin:21-jre-alpine
# Cài FFmpeg
RUN apk add --no-cache ffmpeg
WORKDIR /app
COPY build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]Cho local development, cài FFmpeg:
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# Kiểm tra cài đặt
ffmpeg -version
ffprobe -versionTrích Xuất Video Metadata với ffprobe
Trước khi transcode, chúng ta extract metadata từ video đã upload — duration, resolution, codec, và bitrate. Thông tin này giúp đưa ra quyết định transcode và hiển thị lesson duration cho user.
// src/main/java/com/videoplatform/api/service/VideoMetadataService.java
package com.videoplatform.api.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.stream.Collectors;
@Service
public class VideoMetadataService {
private static final Logger log = LoggerFactory.getLogger(VideoMetadataService.class);
private final ObjectMapper objectMapper = new ObjectMapper();
public VideoMetadata extractMetadata(Path videoPath) {
try {
ProcessBuilder pb = new ProcessBuilder(
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
videoPath.toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
String output;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("ffprobe failed with exit code: " + exitCode);
}
return parseMetadata(output);
} catch (Exception e) {
log.error("Failed to extract metadata from {}", videoPath, e);
return VideoMetadata.unknown();
}
}
private VideoMetadata parseMetadata(String json) throws Exception {
JsonNode root = objectMapper.readTree(json);
// Lấy duration từ format
double duration = root.path("format").path("duration").asDouble(0);
// Tìm video stream
JsonNode streams = root.path("streams");
int width = 0, height = 0;
String codec = "unknown";
for (JsonNode stream : streams) {
if ("video".equals(stream.path("codec_type").asText())) {
width = stream.path("width").asInt(0);
height = stream.path("height").asInt(0);
codec = stream.path("codec_name").asText("unknown");
break;
}
}
return new VideoMetadata(
(int) Math.round(duration),
width,
height,
codec
);
}
public record VideoMetadata(
int durationSeconds,
int width,
int height,
String codec
) {
public static VideoMetadata unknown() {
return new VideoMetadata(0, 0, 0, "unknown");
}
public String resolution() {
return width + "x" + height;
}
}
}Ví dụ ffprobe output (parse nội bộ):
{
"format": {
"duration": "1847.52",
"size": "1572864000",
"bit_rate": "6808000"
},
"streams": [
{
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080
},
{
"codec_type": "audio",
"codec_name": "aac"
}
]
}Transcoding Service
Đây là core của pipeline. Nó chạy FFmpeg để convert raw video thành HLS segment ở nhiều resolution.
// src/main/java/com/videoplatform/api/service/TranscodingService.java
package com.videoplatform.api.service;
import com.videoplatform.api.config.StorageConfig;
import com.videoplatform.api.model.enums.LessonStatus;
import com.videoplatform.api.service.VideoMetadataService.VideoMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@Service
public class TranscodingService {
private static final Logger log = LoggerFactory.getLogger(TranscodingService.class);
private final StorageConfig storageConfig;
private final VideoMetadataService metadataService;
private final TranscodingProgressService progressService;
private final LessonService lessonService;
public TranscodingService(StorageConfig storageConfig,
VideoMetadataService metadataService,
TranscodingProgressService progressService,
LessonService lessonService) {
this.storageConfig = storageConfig;
this.metadataService = metadataService;
this.progressService = progressService;
this.lessonService = lessonService;
}
@Async("transcodingExecutor")
public void transcodeVideo(Long courseId, Long lessonId, String rawVideoPath) {
Path inputPath = Path.of(storageConfig.getBasePath(), rawVideoPath);
Path outputDir = Path.of(
storageConfig.getHlsPath(),
String.valueOf(courseId),
String.valueOf(lessonId)
);
try {
log.info("Starting transcoding: course={} lesson={}", courseId, lessonId);
progressService.updateProgress(lessonId, "TRANSCODING", 0);
// 1. Extract metadata
VideoMetadata metadata = metadataService.extractMetadata(inputPath);
log.info("Video metadata: {}x{}, {}s, codec={}",
metadata.width(), metadata.height(),
metadata.durationSeconds(), metadata.codec());
// Update lesson duration
lessonService.updateDuration(lessonId, metadata.durationSeconds());
// 2. Xác định output resolution
List<TranscodeProfile> profiles = getProfiles(metadata.height());
// 3. Tạo output directory
Files.createDirectories(outputDir);
// 4. Transcode từng resolution
for (int i = 0; i < profiles.size(); i++) {
TranscodeProfile profile = profiles.get(i);
int baseProgress = (i * 90) / profiles.size();
int nextProgress = ((i + 1) * 90) / profiles.size();
transcodeToHls(inputPath, outputDir, profile, lessonId,
metadata.durationSeconds(), baseProgress, nextProgress);
}
// 5. Generate master playlist
generateMasterPlaylist(outputDir, profiles);
// 6. Update lesson status
String hlsPath = String.format("hls/%d/%d/master.m3u8", courseId, lessonId);
lessonService.updateVideoStatus(lessonId, LessonStatus.READY, hlsPath);
progressService.updateProgress(lessonId, "COMPLETE", 100);
log.info("Transcoding complete: course={} lesson={}", courseId, lessonId);
} catch (Exception e) {
log.error("Transcoding failed: course={} lesson={}", courseId, lessonId, e);
lessonService.updateVideoStatus(lessonId, LessonStatus.FAILED, null);
progressService.updateProgress(lessonId, "FAILED", -1);
}
}
private List<TranscodeProfile> getProfiles(int sourceHeight) {
List<TranscodeProfile> profiles = new ArrayList<>();
// Luôn có 360p
profiles.add(new TranscodeProfile("360p", 640, 360, "800k", "96k"));
// Thêm 720p nếu source ít nhất 720p
if (sourceHeight >= 720) {
profiles.add(new TranscodeProfile("720p", 1280, 720, "2500k", "128k"));
}
return profiles;
}
private void transcodeToHls(Path input, Path outputDir, TranscodeProfile profile,
Long lessonId, int totalDuration,
int baseProgress, int nextProgress) throws Exception {
Path profileDir = outputDir.resolve(profile.name());
Files.createDirectories(profileDir);
Path playlistPath = profileDir.resolve("playlist.m3u8");
Path segmentPattern = profileDir.resolve("segment-%03d.ts");
List<String> command = List.of(
"ffmpeg",
"-i", input.toString(),
"-vf", String.format("scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2",
profile.width(), profile.height(),
profile.width(), profile.height()),
"-c:v", "libx264",
"-preset", "medium",
"-b:v", profile.videoBitrate(),
"-maxrate", profile.videoBitrate(),
"-bufsize", String.valueOf(parseBitrate(profile.videoBitrate()) * 2),
"-c:a", "aac",
"-b:a", profile.audioBitrate(),
"-ac", "2",
"-ar", "44100",
"-hls_time", "10",
"-hls_list_size", "0",
"-hls_segment_filename", segmentPattern.toString(),
"-hls_playlist_type", "vod",
"-f", "hls",
"-y",
"-progress", "pipe:1",
playlistPath.toString()
);
log.info("Transcoding to {}: {}", profile.name(), String.join(" ", command));
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
Process process = pb.start();
// Parse FFmpeg progress output
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("out_time_us=")) {
long microseconds = Long.parseLong(line.split("=")[1]);
int currentSeconds = (int) (microseconds / 1_000_000);
if (totalDuration > 0) {
double ratio = (double) currentSeconds / totalDuration;
int progress = baseProgress +
(int) (ratio * (nextProgress - baseProgress));
progress = Math.min(progress, nextProgress);
progressService.updateProgress(lessonId, "TRANSCODING", progress);
}
}
}
}
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("FFmpeg failed for " + profile.name() +
" with exit code: " + exitCode);
}
log.info("Transcoding complete for {}: {}", profile.name(), playlistPath);
}
private void generateMasterPlaylist(Path outputDir, List<TranscodeProfile> profiles) throws Exception {
StringBuilder playlist = new StringBuilder();
playlist.append("#EXTM3U\n");
playlist.append("#EXT-X-VERSION:3\n\n");
for (TranscodeProfile profile : profiles) {
int bandwidth = parseBitrate(profile.videoBitrate()) +
parseBitrate(profile.audioBitrate());
playlist.append(String.format(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d,NAME=\"%s\"\n",
bandwidth, profile.width(), profile.height(), profile.name()
));
playlist.append(profile.name()).append("/playlist.m3u8\n\n");
}
Files.writeString(outputDir.resolve("master.m3u8"), playlist.toString());
}
private int parseBitrate(String bitrate) {
String value = bitrate.replaceAll("[^0-9]", "");
int num = Integer.parseInt(value);
if (bitrate.endsWith("k")) return num * 1000;
if (bitrate.endsWith("m")) return num * 1_000_000;
return num;
}
record TranscodeProfile(
String name,
int width,
int height,
String videoBitrate,
String audioBitrate
) {}
}Giải Thích FFmpeg Command
Hãy phân tích FFmpeg command — nó làm rất nhiều thứ:
| Flag | Mục đích |
|---|---|
-i input.mp4 | File đầu vào |
-vf scale=... | Resize video, giữ aspect ratio, thêm padding nếu cần |
-c:v libx264 | Encode video bằng H.264 (hỗ trợ mọi browser) |
-preset medium | Cân bằng giữa tốc độ encode và kích thước file |
-b:v 2500k | Target video bitrate (720p) |
-maxrate / -bufsize | Giới hạn bitrate cho streaming ổn định |
-c:a aac -b:a 128k | AAC audio ở 128kbps |
-hls_time 10 | Mỗi segment dài 10 giây |
-hls_list_size 0 | Đưa tất cả segment vào playlist (VOD) |
-hls_playlist_type vod | Báo hiệu đây là video-on-demand, không phải live |
-progress pipe:1 | Xuất progress ra stdout để tracking |
-y | Ghi đè output file mà không hỏi |
Logic Chọn Resolution
Chúng ta chỉ transcode sang resolution thấp hơn hoặc bằng source:
Upscale video 480p lên 720p chỉ tốn bandwidth mà không cải thiện chất lượng — nên chúng ta bỏ qua.
Async Processing với @Async
Transcoding mất vài phút (hoặc lâu hơn với file lớn). Không thể block upload request đợi nó xong. Spring @Async chạy transcoding trong background thread.
Cấu Hình Thread Pool
// src/main/java/com/videoplatform/api/config/AsyncConfig.java
package com.videoplatform.api.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "transcodingExecutor")
public Executor transcodingExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 2 job transcode đồng thời
executor.setMaxPoolSize(4); // Burst lên tối đa 4
executor.setQueueCapacity(50); // Queue tối đa 50 job chờ
executor.setThreadNamePrefix("transcode-");
executor.initialize();
return executor;
}
}Tại sao giới hạn 2–4 job đồng thời? FFmpeg rất tốn CPU. Trên VPS 4 core điển hình, chạy hơn 2 transcoding job cùng lúc làm tất cả đều chậm hơn. Tốt hơn là queue rồi xử lý tuần tự.
Progress Tracking trong Redis
Redis lưu transcoding progress real-time để mọi client (bao gồm admin dashboard) có thể poll status:
// src/main/java/com/videoplatform/api/service/TranscodingProgressService.java
package com.videoplatform.api.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Service
public class TranscodingProgressService {
private static final String KEY_PREFIX = "transcode:progress:";
private static final Duration TTL = Duration.ofHours(24);
private final StringRedisTemplate redisTemplate;
public TranscodingProgressService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void updateProgress(Long lessonId, String status, int percentage) {
String key = KEY_PREFIX + lessonId;
Map<String, String> data = new HashMap<>();
data.put("status", status);
data.put("percentage", String.valueOf(percentage));
data.put("updatedAt", String.valueOf(System.currentTimeMillis()));
redisTemplate.opsForHash().putAll(key, data);
redisTemplate.expire(key, TTL);
}
public TranscodeProgress getProgress(Long lessonId) {
String key = KEY_PREFIX + lessonId;
Map<Object, Object> data = redisTemplate.opsForHash().entries(key);
if (data.isEmpty()) {
return new TranscodeProgress("UNKNOWN", 0);
}
return new TranscodeProgress(
(String) data.getOrDefault("status", "UNKNOWN"),
Integer.parseInt((String) data.getOrDefault("percentage", "0"))
);
}
public void clearProgress(Long lessonId) {
redisTemplate.delete(KEY_PREFIX + lessonId);
}
public record TranscodeProgress(String status, int percentage) {}
}Tại sao Redis thay vì database column? Vì progress update xảy ra mỗi vài giây trong lúc transcode. Ghi vào PostgreSQL thường xuyên như vậy rất lãng phí. Redis xử lý dễ dàng, và data này là ephemeral — không cần nó nữa khi transcode xong.
Trigger Transcode Sau Upload
Update upload controller để tự động kick off transcoding:
// Update AdminUploadController.java — sửa method uploadVideo
@PostMapping("/video")
public ResponseEntity<ApiResponse<UploadResponse>> uploadVideo(
@RequestParam("file") MultipartFile file,
@RequestParam("courseId") Long courseId,
@RequestParam("lessonId") Long lessonId) {
// 1. Validate file
validationService.validateVideoFile(file);
// 2. Lưu lên disk
String videoPath = storageService.storeRawVideo(file, courseId, lessonId);
// 3. Update lesson record
lessonService.updateVideoPath(lessonId, videoPath);
// 4. Trigger async transcoding
transcodingService.transcodeVideo(courseId, lessonId, videoPath);
// 5. Trả response (transcoding chạy ở background)
UploadResponse response = new UploadResponse(
videoPath,
file.getSize(),
file.getOriginalFilename(),
file.getContentType()
);
return ResponseEntity.ok(
ApiResponse.success("Video uploaded. Transcoding started.", response)
);
}Response trả về ngay sau khi file được lưu. Transcoding chạy ở background — admin có thể kiểm tra progress qua status endpoint.
Thêm LessonService Method
// Thêm vào LessonService.java
@Transactional
public void updateDuration(Long lessonId, int durationSeconds) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
lesson.setDuration(durationSeconds);
lessonRepository.save(lesson);
// Update course total duration
courseService.refreshCourseStats(lesson.getSection().getCourse().getId());
}
@Transactional
public void updateVideoStatus(Long lessonId, LessonStatus status, String hlsPath) {
Lesson lesson = lessonRepository.findById(lessonId)
.orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
lesson.setStatus(status);
if (hlsPath != null) {
lesson.setVideoPath(hlsPath);
}
lessonRepository.save(lesson);
}Transcoding Status API
Thêm endpoint kiểm tra transcoding progress:
// Thêm vào AdminUploadController.java
@GetMapping("/transcode-status/{lessonId}")
public ResponseEntity<ApiResponse<TranscodeStatusResponse>> getTranscodeStatus(
@PathVariable Long lessonId) {
var progress = progressService.getProgress(lessonId);
return ResponseEntity.ok(ApiResponse.success(
new TranscodeStatusResponse(
progress.status(),
progress.percentage()
)
));
}// src/main/java/com/videoplatform/api/dto/response/TranscodeStatusResponse.java
package com.videoplatform.api.dto.response;
public record TranscodeStatusResponse(
String status,
int percentage
) {}Admin UI: Transcoding Progress
Update lesson row trong admin dashboard để hiện transcoding status:
// web/src/components/admin/TranscodeStatus.tsx
"use client";
import { useEffect, useState } from "react";
import { adminApi } from "@/lib/admin-api";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Loader2, CheckCircle2, XCircle, Clock } from "lucide-react";
interface TranscodeStatusProps {
lessonId: number;
lessonStatus: string;
}
export function TranscodeStatus({ lessonId, lessonStatus }: TranscodeStatusProps) {
const [progress, setProgress] = useState<{ status: string; percentage: number } | null>(null);
const [polling, setPolling] = useState(lessonStatus === "PROCESSING");
useEffect(() => {
if (!polling) return;
const interval = setInterval(async () => {
try {
const data = await adminApi.getTranscodeStatus(lessonId);
setProgress(data);
if (data.status === "COMPLETE" || data.status === "FAILED") {
setPolling(false);
}
} catch {
setPolling(false);
}
}, 2000); // Poll mỗi 2 giây
return () => clearInterval(interval);
}, [lessonId, polling]);
if (lessonStatus === "READY") {
return (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="h-3 w-3 mr-1" />
Ready
</Badge>
);
}
if (lessonStatus === "FAILED") {
return (
<Badge variant="outline" className="text-red-600">
<XCircle className="h-3 w-3 mr-1" />
Failed
</Badge>
);
}
if (lessonStatus === "PROCESSING" && progress) {
return (
<div className="flex items-center gap-2 min-w-[150px]">
<Loader2 className="h-3 w-3 animate-spin text-primary" />
<Progress value={progress.percentage} className="h-1.5 flex-1" />
<span className="text-xs text-muted-foreground">{progress.percentage}%</span>
</div>
);
}
if (lessonStatus === "PROCESSING") {
return (
<Badge variant="outline" className="text-yellow-600">
<Clock className="h-3 w-3 mr-1" />
Queued
</Badge>
);
}
return null;
}// Thêm vào admin-api.ts
getTranscodeStatus: (lessonId: number) =>
api
.get<{ data: { status: string; percentage: number } }>(
`/api/admin/upload/transcode-status/${lessonId}`
)
.then((r) => r.data.data),Complete Transcoding Pipeline
Đây là full flow từ upload đến HLS có thể phát:
Ví Dụ Master Playlist
Đây là master.m3u8 được sinh ra:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=896000,RESOLUTION=640x360,NAME="360p"
360p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,NAME="720p"
720p/playlist.m3u8Và playlist.m3u8 cho từng quality:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:10.000000,
segment-000.ts
#EXTINF:10.000000,
segment-001.ts
#EXTINF:10.000000,
segment-002.ts
#EXTINF:7.520000,
segment-003.ts
#EXT-X-ENDLISTTest Pipeline
1. Upload và Transcode
# Upload video — transcoding bắt đầu tự động
curl -X POST http://localhost:8080/api/admin/upload/video \
-H "Authorization: Bearer ${TOKEN}" \
-F "[email protected]" \
-F "courseId=1" \
-F "lessonId=1"2. Kiểm Tra Progress
# Poll transcoding status
watch -n 2 'curl -s http://localhost:8080/api/admin/upload/transcode-status/1 \
-H "Authorization: Bearer ${TOKEN}" | jq'Bạn sẽ thấy progress tăng dần:
{ "status": "TRANSCODING", "percentage": 23 }
{ "status": "TRANSCODING", "percentage": 47 }
{ "status": "TRANSCODING", "percentage": 71 }
{ "status": "TRANSCODING", "percentage": 95 }
{ "status": "COMPLETE", "percentage": 100 }3. Kiểm Tra HLS Output
# Xem file được sinh ra
ls -la /data/videos/hls/1/1/
# master.m3u8 360p/ 720p/
cat /data/videos/hls/1/1/master.m3u8
ls /data/videos/hls/1/1/360p/
# playlist.m3u8 segment-000.ts segment-001.ts ...4. Test Phát Lại (Kiểm Tra Nhanh)
Bạn có thể test HLS playback local bằng ffplay:
ffplay /data/videos/hls/1/1/master.m3u8Hoặc serve bằng Python HTTP server rồi mở trong browser với HLS player extension.
Lỗi Thường Gặp
1. Chạy FFmpeg Đồng Bộ
Không bao giờ chạy FFmpeg trong request thread — nó block HTTP connection hàng phút:
// SAI — block request
@PostMapping("/upload")
public ResponseEntity<?> upload(MultipartFile file) {
storeFile(file);
runFFmpeg(file); // Block 5+ phút!
return ResponseEntity.ok("Done");
}
// ĐÚNG — async background processing
@PostMapping("/upload")
public ResponseEntity<?> upload(MultipartFile file) {
storeFile(file);
transcodingService.transcodeVideo(...); // Trả về ngay
return ResponseEntity.ok("Transcoding started");
}2. Không Giới Hạn Thread Pool Size
Unlimited FFmpeg process sẽ giết server:
// SAI — unlimited thread
@Async
public void transcode() { ... }
// ĐÚNG — bounded thread pool
@Async("transcodingExecutor") // Dùng pool đã cấu hình, tối đa 4 thread
public void transcode() { ... }3. Bỏ Qua FFmpeg Exit Code
FFmpeg trả non-zero exit code khi fail. Luôn kiểm tra:
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("FFmpeg failed: exit code " + exitCode);
}4. Hardcode Resolution
Nếu ai đó upload video 480p, đừng upscale lên 720p:
// SAI — luôn transcode cả hai
profiles.add(new TranscodeProfile("360p", ...));
profiles.add(new TranscodeProfile("720p", ...));
// ĐÚNG — kiểm tra source resolution trước
if (sourceHeight >= 720) {
profiles.add(new TranscodeProfile("720p", ...));
}Bước Tiếp Theo
HLS file đã trên disk, nhưng chưa có cách serve chúng tới browser. Trong Post #8, chúng ta sẽ setup secure video streaming:
- Cấu hình Nginx để serve HLS file
- Module
secure_linkcho token-based URL validation - Signed URL generation trong Spring Boot
- CORS header cho cross-origin video playback
- Ngăn chặn direct download video segment
Đã đến lúc serve HLS stream ra thế giới — một cách an toàn.
Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 5: Video Upload & Storage
Tiếp theo: Phase 7: Secure Video Streaming
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.