Harden relay preview mp4 handling
这个提交包含在:
@@ -8,6 +8,25 @@ export type ChangeLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "2026.03.17-live-camera-relay-mp4-hardening",
|
||||||
|
releaseDate: "2026-03-17",
|
||||||
|
repoVersion: "pending",
|
||||||
|
summary:
|
||||||
|
"修复实时分析 relay 预览在 Chrome `mp4` 分段下容易失效的问题,并让 live-camera 录制优先回到更稳定的 `webm`。",
|
||||||
|
features: [
|
||||||
|
"media 服务在 relay 会话收到第一段 `mp4` 时会额外保留初始化片段,后续滚动缓存即使裁掉旧分段,也能继续为 preview 重建可解码的输入源",
|
||||||
|
"relay preview 构建会跳过明显异常的小 `mp4` 分段,并优先尝试把保留的初始化片段与当前缓存拼成单一输入后再转成 `preview.webm`",
|
||||||
|
"如果 relay preview 本轮重建失败,但磁盘上仍有上一版可播放 `preview.webm`,worker 会保留旧预览继续对 viewer 提供播放,而不是直接把同步观看打成永久失败",
|
||||||
|
"live-camera 的合成录制 mime 选择已改为优先 `video/webm`,Chrome 不再默认上传 fragmented `mp4` relay 分段,从源头减少 `trex/tfhd` 类 ffmpeg 拼接失败",
|
||||||
|
],
|
||||||
|
tests: [
|
||||||
|
"cd media && go test ./...",
|
||||||
|
"pnpm check",
|
||||||
|
"pnpm build",
|
||||||
|
"部署后线上 smoke: 重新开始一条 `/live-camera` 实时分析,确认 relay 新分段优先为 `webm`,viewer 端继续通过 `/media/assets/sessions/.../preview.webm` 拉流且不再快速掉入 `previewStatus=failed`",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "2026.03.17-live-camera-media-asset-url",
|
version: "2026.03.17-live-camera-media-asset-url",
|
||||||
releaseDate: "2026-03-17",
|
releaseDate: "2026-03-17",
|
||||||
|
|||||||
@@ -392,12 +392,6 @@ function pickRecorderMimeType() {
|
|||||||
const supported =
|
const supported =
|
||||||
typeof MediaRecorder !== "undefined" &&
|
typeof MediaRecorder !== "undefined" &&
|
||||||
typeof MediaRecorder.isTypeSupported === "function";
|
typeof MediaRecorder.isTypeSupported === "function";
|
||||||
if (
|
|
||||||
supported &&
|
|
||||||
MediaRecorder.isTypeSupported("video/mp4;codecs=avc1.42E01E,mp4a.40.2")
|
|
||||||
) {
|
|
||||||
return "video/mp4";
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
supported &&
|
supported &&
|
||||||
MediaRecorder.isTypeSupported("video/webm;codecs=vp9,opus")
|
MediaRecorder.isTypeSupported("video/webm;codecs=vp9,opus")
|
||||||
@@ -410,6 +404,12 @@ function pickRecorderMimeType() {
|
|||||||
) {
|
) {
|
||||||
return "video/webm;codecs=vp8,opus";
|
return "video/webm;codecs=vp8,opus";
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
supported &&
|
||||||
|
MediaRecorder.isTypeSupported("video/mp4;codecs=avc1.42E01E,mp4a.40.2")
|
||||||
|
) {
|
||||||
|
return "video/mp4";
|
||||||
|
}
|
||||||
return "video/webm";
|
return "video/webm";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# Tennis Training Hub - 变更日志
|
# Tennis Training Hub - 变更日志
|
||||||
|
|
||||||
|
## 2026.03.17-live-camera-relay-mp4-hardening (2026-03-17)
|
||||||
|
|
||||||
|
### 功能更新
|
||||||
|
|
||||||
|
- 修复实时分析 relay 预览在 Chrome `mp4` 分段下容易失效的问题;media 服务现在会在 relay 会话收到第一段 `mp4` 时额外保留初始化片段,供后续滚动 preview 重建使用
|
||||||
|
- relay preview 构建会跳过明显异常的小 `mp4` 分段,并优先把初始化片段和当前缓存合成单一输入后再转成 `preview.webm`,降低 `trex/tfhd` 缺失导致的 ffmpeg 失败率
|
||||||
|
- 如果 relay preview 本轮重建失败,但磁盘上仍有上一版可播放 `preview.webm`,worker 会保留旧预览继续服务 viewer,而不是直接把同步观看打成永久失败
|
||||||
|
- `live-camera` 合成录制的 mime 选择已经改成优先 `video/webm`;Chrome 不再默认优先上传 fragmented `mp4` relay 分段,从源头减少 `concat failed` 与 `previewStatus=failed`
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `cd media && go test ./...`
|
||||||
|
- `pnpm check`
|
||||||
|
- `pnpm build`
|
||||||
|
- 部署后线上 smoke:重新开始一条 `/live-camera` 实时分析,确认 relay 新分段优先为 `webm`,viewer 继续通过 `/media/assets/sessions/.../preview.webm` 拉流,且不再快速掉入 `previewStatus=failed`
|
||||||
|
|
||||||
|
### 线上 smoke
|
||||||
|
|
||||||
|
- 待本次构建部署后再次验证公开站点是否已切到包含此修复的新资源 revision
|
||||||
|
- 待重新开始一条新的实时分析会话后,继续验证 relay 分段格式、preview 更新稳定性和 viewer 播放状态
|
||||||
|
|
||||||
|
### 仓库版本
|
||||||
|
|
||||||
|
- `pending`
|
||||||
|
|
||||||
## 2026.03.17-live-camera-media-asset-url (2026-03-17)
|
## 2026.03.17-live-camera-media-asset-url (2026-03-17)
|
||||||
|
|
||||||
### 功能更新
|
### 功能更新
|
||||||
|
|||||||
150
media/main.go
150
media/main.go
@@ -118,6 +118,7 @@ type Session struct {
|
|||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
FinalizedAt string `json:"finalizedAt,omitempty"`
|
FinalizedAt string `json:"finalizedAt,omitempty"`
|
||||||
PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
|
PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
|
||||||
|
RelayInitFilename string `json:"relayInitFilename,omitempty"`
|
||||||
StreamConnected bool `json:"streamConnected"`
|
StreamConnected bool `json:"streamConnected"`
|
||||||
LastStreamAt string `json:"lastStreamAt,omitempty"`
|
LastStreamAt string `json:"lastStreamAt,omitempty"`
|
||||||
ViewerCount int `json:"viewerCount"`
|
ViewerCount int `json:"viewerCount"`
|
||||||
@@ -258,6 +259,10 @@ func (s *sessionStore) publicDir(id string) string {
|
|||||||
return filepath.Join(s.public, "sessions", id)
|
return filepath.Join(s.public, "sessions", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *sessionStore) relayInitPath(id string) string {
|
||||||
|
return filepath.Join(s.sessionDir(id), "relay-init.mp4")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *sessionStore) liveFramePath(id string) string {
|
func (s *sessionStore) liveFramePath(id string) string {
|
||||||
return filepath.Join(s.publicDir(id), "live-frame.jpg")
|
return filepath.Join(s.publicDir(id), "live-frame.jpg")
|
||||||
}
|
}
|
||||||
@@ -974,6 +979,7 @@ func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWrite
|
|||||||
}
|
}
|
||||||
|
|
||||||
removedSegments := []SegmentMeta{}
|
removedSegments := []SegmentMeta{}
|
||||||
|
persistRelayInit := false
|
||||||
session, err := m.store.updateSession(sessionID, func(session *Session) error {
|
session, err := m.store.updateSession(sessionID, func(session *Session) error {
|
||||||
meta := SegmentMeta{
|
meta := SegmentMeta{
|
||||||
Sequence: sequence,
|
Sequence: sequence,
|
||||||
@@ -994,6 +1000,10 @@ func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWrite
|
|||||||
if !found {
|
if !found {
|
||||||
session.Segments = append(session.Segments, meta)
|
session.Segments = append(session.Segments, meta)
|
||||||
}
|
}
|
||||||
|
if session.Purpose == PurposeRelay && extension == "mp4" && session.RelayInitFilename == "" && sequence <= 1 {
|
||||||
|
session.RelayInitFilename = filename
|
||||||
|
persistRelayInit = true
|
||||||
|
}
|
||||||
sortSegmentsBySequence(session.Segments)
|
sortSegmentsBySequence(session.Segments)
|
||||||
if session.Purpose == PurposeRelay {
|
if session.Purpose == PurposeRelay {
|
||||||
var kept []SegmentMeta
|
var kept []SegmentMeta
|
||||||
@@ -1008,6 +1018,11 @@ func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWrite
|
|||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if persistRelayInit {
|
||||||
|
if copyErr := copyFile(segmentPath, m.store.relayInitPath(sessionID)); copyErr != nil {
|
||||||
|
log.Printf("failed to persist relay init segment for %s: %v", sessionID, copyErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, segment := range removedSegments {
|
for _, segment := range removedSegments {
|
||||||
segmentPath := filepath.Join(m.store.segmentsDir(sessionID), segment.Filename)
|
segmentPath := filepath.Join(m.store.segmentsDir(sessionID), segment.Filename)
|
||||||
if removeErr := os.Remove(segmentPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
if removeErr := os.Remove(segmentPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||||
@@ -1173,15 +1188,61 @@ func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool
|
|||||||
outputMP4 := filepath.Join(publicDir, baseName+".mp4")
|
outputMP4 := filepath.Join(publicDir, baseName+".mp4")
|
||||||
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
|
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
|
||||||
|
|
||||||
|
validSegments := make([]SegmentMeta, 0, len(session.Segments))
|
||||||
inputs := make([]string, 0, len(session.Segments))
|
inputs := make([]string, 0, len(session.Segments))
|
||||||
sortSegmentsBySequence(session.Segments)
|
sortSegmentsBySequence(session.Segments)
|
||||||
for _, segment := range session.Segments {
|
for _, segment := range session.Segments {
|
||||||
inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename))
|
inputPath := filepath.Join(store.segmentsDir(sessionID), segment.Filename)
|
||||||
|
info, statErr := os.Stat(inputPath)
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shouldSkipSegment(segment, info.Size()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validSegments = append(validSegments, segment)
|
||||||
|
inputs = append(inputs, inputPath)
|
||||||
|
}
|
||||||
|
if len(inputs) == 0 {
|
||||||
|
return markProcessingError(store, sessionID, errors.New("no valid uploaded segments found"), finalize)
|
||||||
|
}
|
||||||
|
if !finalize && session.Purpose == PurposeRelay && usesMP4Segments(validSegments) {
|
||||||
|
mergedInput, cleanup, mergeErr := buildRelayMP4Source(store, session, validSegments, inputs)
|
||||||
|
if cleanup != nil {
|
||||||
|
defer cleanup()
|
||||||
|
}
|
||||||
|
if mergeErr == nil {
|
||||||
|
transcodeErr := runFFmpeg(
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
mergedInput,
|
||||||
|
"-c:v",
|
||||||
|
"libvpx-vp9",
|
||||||
|
"-b:v",
|
||||||
|
"1800k",
|
||||||
|
"-c:a",
|
||||||
|
"libopus",
|
||||||
|
outputWebM,
|
||||||
|
)
|
||||||
|
if transcodeErr == nil {
|
||||||
|
goto finalizePlayback
|
||||||
|
}
|
||||||
|
mergeErr = transcodeErr
|
||||||
}
|
}
|
||||||
if err := writeConcatList(listFile, inputs); err != nil {
|
if err := writeConcatList(listFile, inputs); err != nil {
|
||||||
return markProcessingError(store, sessionID, err, finalize)
|
return markProcessingError(store, sessionID, err, finalize)
|
||||||
}
|
}
|
||||||
|
copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM)
|
||||||
|
if copyErr != nil {
|
||||||
|
reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM)
|
||||||
|
if reencodeErr != nil {
|
||||||
|
return markProcessingError(store, sessionID, fmt.Errorf("relay mp4 preview failed: %w / %v / %v", mergeErr, copyErr, reencodeErr), finalize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := writeConcatList(listFile, inputs); err != nil {
|
||||||
|
return markProcessingError(store, sessionID, err, finalize)
|
||||||
|
}
|
||||||
if len(inputs) == 1 {
|
if len(inputs) == 1 {
|
||||||
body, copyErr := os.ReadFile(inputs[0])
|
body, copyErr := os.ReadFile(inputs[0])
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
@@ -1199,7 +1260,9 @@ func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizePlayback:
|
||||||
if finalize {
|
if finalize {
|
||||||
mp4Err := runFFmpeg("-y", "-i", outputWebM, "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-c:a", "aac", "-movflags", "+faststart", outputMP4)
|
mp4Err := runFFmpeg("-y", "-i", outputWebM, "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-c:a", "aac", "-movflags", "+faststart", outputMP4)
|
||||||
if mp4Err != nil {
|
if mp4Err != nil {
|
||||||
@@ -1226,7 +1289,7 @@ func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool
|
|||||||
|
|
||||||
_, updateErr := store.updateSession(sessionID, func(session *Session) error {
|
_, updateErr := store.updateSession(sessionID, func(session *Session) error {
|
||||||
session.Playback.PreviewURL = previewURL
|
session.Playback.PreviewURL = previewURL
|
||||||
session.PreviewSegments = len(inputs)
|
session.PreviewSegments = len(validSegments)
|
||||||
session.PreviewUpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
session.PreviewUpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
session.PreviewStatus = PreviewReady
|
session.PreviewStatus = PreviewReady
|
||||||
session.LastError = ""
|
session.LastError = ""
|
||||||
@@ -1249,6 +1312,15 @@ func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool
|
|||||||
|
|
||||||
func markProcessingError(store *sessionStore, sessionID string, err error, finalize bool) error {
|
func markProcessingError(store *sessionStore, sessionID string, err error, finalize bool) error {
|
||||||
_, _ = store.updateSession(sessionID, func(session *Session) error {
|
_, _ = store.updateSession(sessionID, func(session *Session) error {
|
||||||
|
if !finalize {
|
||||||
|
previewPath := filepath.Join(store.publicDir(sessionID), "preview.webm")
|
||||||
|
if info, statErr := os.Stat(previewPath); statErr == nil && info.Size() > 0 {
|
||||||
|
session.PreviewStatus = PreviewReady
|
||||||
|
session.Playback.PreviewURL = fmt.Sprintf("/media/assets/sessions/%s/preview.webm", sessionID)
|
||||||
|
session.LastError = err.Error()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
session.PreviewStatus = PreviewFailed
|
session.PreviewStatus = PreviewFailed
|
||||||
if finalize {
|
if finalize {
|
||||||
session.ArchiveStatus = ArchiveFailed
|
session.ArchiveStatus = ArchiveFailed
|
||||||
@@ -1268,6 +1340,78 @@ func writeConcatList(path string, inputs []string) error {
|
|||||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
|
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func usesMP4Segments(segments []SegmentMeta) bool {
|
||||||
|
for _, segment := range segments {
|
||||||
|
if strings.HasSuffix(strings.ToLower(segment.Filename), ".mp4") || strings.Contains(strings.ToLower(segment.ContentType), "mp4") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipSegment(segment SegmentMeta, sizeBytes int64) bool {
|
||||||
|
if sizeBytes <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(strings.ToLower(segment.Filename), ".mp4") && sizeBytes < 4096 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRelayMP4Source(store *sessionStore, session *Session, segments []SegmentMeta, inputs []string) (string, func(), error) {
|
||||||
|
sourceFiles := make([]string, 0, len(inputs)+1)
|
||||||
|
initPath := store.relayInitPath(session.ID)
|
||||||
|
if session.RelayInitFilename != "" && len(segments) > 0 && segments[0].Filename != session.RelayInitFilename {
|
||||||
|
if info, err := os.Stat(initPath); err == nil && info.Size() > 0 {
|
||||||
|
sourceFiles = append(sourceFiles, initPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceFiles = append(sourceFiles, inputs...)
|
||||||
|
if len(sourceFiles) == 0 {
|
||||||
|
return "", nil, errors.New("no relay mp4 source segments found")
|
||||||
|
}
|
||||||
|
mergedPath := filepath.Join(store.sessionDir(session.ID), "relay-preview-source.mp4")
|
||||||
|
output, err := os.Create(mergedPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer output.Close()
|
||||||
|
for _, source := range sourceFiles {
|
||||||
|
input, openErr := os.Open(source)
|
||||||
|
if openErr != nil {
|
||||||
|
return "", nil, openErr
|
||||||
|
}
|
||||||
|
if _, copyErr := io.Copy(output, input); copyErr != nil {
|
||||||
|
input.Close()
|
||||||
|
return "", nil, copyErr
|
||||||
|
}
|
||||||
|
if closeErr := input.Close(); closeErr != nil {
|
||||||
|
return "", nil, closeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mergedPath, func() {
|
||||||
|
_ = os.Remove(mergedPath)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(source string, target string) error {
|
||||||
|
input, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer input.Close()
|
||||||
|
output, err := os.Create(target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer output.Close()
|
||||||
|
if _, err := io.Copy(output, input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func runFFmpeg(args ...string) error {
|
func runFFmpeg(args ...string) error {
|
||||||
cmd := exec.Command("ffmpeg", args...)
|
cmd := exec.Command("ffmpeg", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -410,6 +411,186 @@ func TestProcessRelayPreviewPublishesBufferedWebM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleSegmentUploadPersistsRelayMP4InitSegment(t *testing.T) {
|
||||||
|
store, err := newSessionStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newSessionStore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newMediaServer(store)
|
||||||
|
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay MP4", Purpose: "relay", RelayBufferSeconds: 120})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/segments?sequence=1&durationMs=10000", strings.NewReader("mp4-init"))
|
||||||
|
req.Header.Set("Content-Type", "video/mp4;codecs=avc1")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
server.routes().ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("expected segment upload 202, got %d", res.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := store.getSession(session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getSession: %v", err)
|
||||||
|
}
|
||||||
|
if current.RelayInitFilename != "000001.mp4" {
|
||||||
|
t.Fatalf("expected relay init filename to be recorded, got %q", current.RelayInitFilename)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(store.relayInitPath(session.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read relay init: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "mp4-init" {
|
||||||
|
t.Fatalf("unexpected relay init contents: %q", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessRelayPreviewUsesPersistedInitForMP4Fragments(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store, err := newSessionStore(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newSessionStore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay MP4 Preview", Purpose: "relay", RelayBufferSeconds: 120})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(store.relayInitPath(session.ID), []byte(strings.Repeat("i", 6000)), 0o644); err != nil {
|
||||||
|
t.Fatalf("write relay init: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000082.mp4"), []byte(strings.Repeat("a", 6000)), 0o644); err != nil {
|
||||||
|
t.Fatalf("write segment 82: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000083.mp4"), []byte(strings.Repeat("b", 6000)), 0o644); err != nil {
|
||||||
|
t.Fatalf("write segment 83: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.updateSession(session.ID, func(current *Session) error {
|
||||||
|
current.Purpose = PurposeRelay
|
||||||
|
current.RelayInitFilename = "000001.mp4"
|
||||||
|
current.Segments = []SegmentMeta{
|
||||||
|
{
|
||||||
|
Sequence: 82,
|
||||||
|
Filename: "000082.mp4",
|
||||||
|
DurationMS: 10000,
|
||||||
|
SizeBytes: 6000,
|
||||||
|
ContentType: "video/mp4;codecs=avc1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Sequence: 83,
|
||||||
|
Filename: "000083.mp4",
|
||||||
|
DurationMS: 10000,
|
||||||
|
SizeBytes: 6000,
|
||||||
|
ContentType: "video/mp4;codecs=avc1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("updateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeFFmpeg := filepath.Join(tempDir, "ffmpeg")
|
||||||
|
script := "#!/bin/sh\ninput=''\noutput=''\nprev=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-i' ]; then input=\"$arg\"; fi\n prev=\"$arg\"\n output=\"$arg\"\ndone\nif [ -n \"$input\" ] && [ -f \"$input\" ]; then cp \"$input\" \"$output\"; else : > \"$output\"; fi\n"
|
||||||
|
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", tempDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
if err := processRollingPreview(store, session.ID); err != nil {
|
||||||
|
t.Fatalf("processRollingPreview: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := store.getSession(session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getSession: %v", err)
|
||||||
|
}
|
||||||
|
if current.PreviewStatus != PreviewReady {
|
||||||
|
t.Fatalf("expected preview ready, got %s", current.PreviewStatus)
|
||||||
|
}
|
||||||
|
if current.Playback.PreviewURL == "" {
|
||||||
|
t.Fatalf("expected preview url to be populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessRelayPreviewKeepsPreviousPreviewOnFailure(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
store, err := newSessionStore(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newSessionStore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Existing Preview", Purpose: "relay", RelayBufferSeconds: 120})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(store.publicDir(session.ID), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir public dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.publicDir(session.ID), "preview.webm"), []byte("existing-preview"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write preview: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000001.webm"), []byte("segment-one"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write segment 1: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000002.webm"), []byte("segment-two"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write segment 2: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.updateSession(session.ID, func(current *Session) error {
|
||||||
|
current.Purpose = PurposeRelay
|
||||||
|
current.PreviewStatus = PreviewReady
|
||||||
|
current.Playback.PreviewURL = fmt.Sprintf("/media/assets/sessions/%s/preview.webm", session.ID)
|
||||||
|
current.Segments = []SegmentMeta{
|
||||||
|
{
|
||||||
|
Sequence: 1,
|
||||||
|
Filename: "000001.webm",
|
||||||
|
DurationMS: 10000,
|
||||||
|
SizeBytes: int64(len("segment-one")),
|
||||||
|
ContentType: "video/webm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Sequence: 2,
|
||||||
|
Filename: "000002.webm",
|
||||||
|
DurationMS: 10000,
|
||||||
|
SizeBytes: int64(len("segment-two")),
|
||||||
|
ContentType: "video/webm",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("updateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeFFmpeg := filepath.Join(tempDir, "ffmpeg")
|
||||||
|
script := "#!/bin/sh\nexit 1\n"
|
||||||
|
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", tempDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
if err := processRollingPreview(store, session.ID); err == nil {
|
||||||
|
t.Fatalf("expected processRollingPreview to surface failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := store.getSession(session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getSession: %v", err)
|
||||||
|
}
|
||||||
|
if current.PreviewStatus != PreviewReady {
|
||||||
|
t.Fatalf("expected previous preview to remain ready, got %s", current.PreviewStatus)
|
||||||
|
}
|
||||||
|
if current.Playback.PreviewURL == "" {
|
||||||
|
t.Fatalf("expected preview url to remain available")
|
||||||
|
}
|
||||||
|
if current.LastError == "" {
|
||||||
|
t.Fatalf("expected last error to be recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPruneExpiredRelaySessionsRemovesOldCache(t *testing.T) {
|
func TestPruneExpiredRelaySessionsRemovesOldCache(t *testing.T) {
|
||||||
store, err := newSessionStore(t.TempDir())
|
store, err := newSessionStore(t.TempDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户