Compare commits

...

4 Commits

Author SHA1 Message Date
Cédric Verstraeten
faa3b4eabb Merge pull request #312 from kerberos-io/fix/moq-double-pts-insertion
fix/moq-double-pts-insertion
2026-08-06 22:46:30 +02:00
Cédric Verstraeten
f0a6eb7d98 Merge pull request #311 from kerberos-io/fix/preserve-recording-fps-precision
preserve-recording-fps-precision
2026-08-06 21:19:18 +02:00
Cédric Verstraeten
33a58cddf7 Fix live stream presentation timestamps
Use capture presentation time directly for MoQ timestamps instead of adding composition time, which is already reflected in PTS.
2026-08-06 21:05:13 +02:00
Kilian Boute
fea6d81246 prevent fps rounding 2026-08-06 17:46:38 +02:00
8 changed files with 38 additions and 18 deletions

View File

@@ -62,7 +62,7 @@ func recordingUploadMetadata(name, deviceKey string, timestamp int64, mp4Video *
}
value := mp4Video.AverageFPS()
if value > 0 && value <= 240 && !math.IsInf(value, 0) && !math.IsNaN(value) {
metadata.FPS = int(math.Floor(value))
metadata.FPS = value
}
return metadata
}

View File

@@ -29,9 +29,13 @@ func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
if err := json.Unmarshal(got, &stored); err != nil {
t.Fatalf("decode upload marker: %v", err)
}
if stored.FileName != "recording.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || stored.FPS != 29 {
expectedFPS := mp4Video.AverageFPS()
if stored.FileName != "recording.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 {
t.Fatalf("upload marker = %+v", stored)
}
if stored.FPS == math.Floor(stored.FPS) {
t.Fatalf("upload marker FPS = %v, want fractional precision", stored.FPS)
}
}
func TestQueueRecordingForUploadKeepsUnknownFPSCompatible(t *testing.T) {
@@ -44,7 +48,7 @@ func TestQueueRecordingForUploadKeepsUnknownFPSCompatible(t *testing.T) {
metadata := models.RecordingUploadMetadata{FileName: "recording.mp4"}
if fps >= 1 && fps <= 240 && !math.IsNaN(fps) && !math.IsInf(fps, 0) {
metadata.FPS = int(math.Floor(fps))
metadata.FPS = fps
}
queueRecordingForUpload(configDirectory, metadata)

View File

@@ -59,6 +59,16 @@ func BroadcastPath(prefix string, deviceKey string) string {
return prefix + "/" + strings.Trim(deviceKey, "/") + "/live.hang"
}
// TimestampUs converts the capture presentation timestamp from milliseconds.
// CompositionTime must not be added: it is already represented in the PTS and
// is only used by muxers to derive DTS for streams containing B-frames.
func TimestampUs(presentationTimeMs int64) uint64 {
if presentationTimeMs < 0 {
return 0
}
return uint64(presentationTimeMs) * 1000
}
func hasAnnexBStartCode(payload []byte) bool {
return len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 &&
((payload[2] == 0 && payload[3] == 1) || payload[2] == 1)

View File

@@ -72,3 +72,12 @@ func TestBroadcastPath(t *testing.T) {
t.Fatalf("BroadcastPath() default = %q", got)
}
}
func TestTimestampUs(t *testing.T) {
if got := TimestampUs(1234); got != 1_234_000 {
t.Fatalf("TimestampUs() = %d, want 1234000", got)
}
if got := TimestampUs(-1); got != 0 {
t.Fatalf("TimestampUs() negative = %d, want 0", got)
}
}

View File

@@ -152,17 +152,13 @@ func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
writing = true
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; broadcast is live")
}
presentationTimeMs := packet.Time + packet.CompositionTime
if presentationTimeMs < 0 {
presentationTimeMs = 0
}
payload, err := livemoq.NormalizeH264AccessUnit(packet.Data)
if err != nil {
return fmt.Errorf("normalize H.264 access unit: %w", err)
}
frame := moq.Frame{
Payload: payload,
TimestampUs: uint64(presentationTimeMs) * 1000,
TimestampUs: livemoq.TimestampUs(packet.Time),
}
if err := stream.WriteFrame(frame); err != nil {
return fmt.Errorf("write H.264 access unit: %w", err)

View File

@@ -28,10 +28,10 @@ func queuedRecordingFPS(fileName string) string {
marker := strings.TrimSpace(string(value))
if strings.HasPrefix(marker, "{") {
metadata, ok := decodeRecordingUploadMetadata(value)
if !ok || metadata.FPS <= 0 || metadata.FPS > 240 {
if !ok || metadata.FPS <= 0 || metadata.FPS > 240 || math.IsInf(metadata.FPS, 0) || math.IsNaN(metadata.FPS) {
return ""
}
return strconv.Itoa(metadata.FPS)
return strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
}
// Compatibility with markers created before upload metadata used JSON.

View File

@@ -272,7 +272,7 @@ func TestUploadVaultResumable_HappyPath(t *testing.T) {
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("x"), 4096)
withRecording(t, fileName, payload)
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":29}`)
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":29.97}`)
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
@@ -289,8 +289,8 @@ func TestUploadVaultResumable_HappyPath(t *testing.T) {
}
posts := srv.requestsForMethod(http.MethodPost)
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
if got := metadata["fps"]; got != "29" {
t.Fatalf("POST metadata fps = %q, want %q", got, "29")
if got := metadata["fps"]; got != "29.97" {
t.Fatalf("POST metadata fps = %q, want %q", got, "29.97")
}
if got := metadata["duration"]; got != "20452" {
t.Fatalf("POST metadata duration = %q, want %q", got, "20452")
@@ -307,6 +307,7 @@ func TestQueuedRecordingFPSValidation(t *testing.T) {
want string
}{
{name: "json", fps: `{"fps":29}`, want: "29"},
{name: "json fractional", fps: `{"fps":17.35}`, want: "17.35"},
{name: "json with future field", fps: `{"fps":29,"codec":"h264"}`, want: "29"},
{name: "json without fps", fps: `{}`},
{name: "json invalid fps", fps: `{"fps":241}`},

View File

@@ -11,11 +11,11 @@ const RecordingUploadMetadataExtension = ".metadata"
// with a recording. New optional fields can be added without changing the queue
// mechanism or breaking older agents.
type RecordingUploadMetadata struct {
FileName string `json:"filename"`
DeviceKey string `json:"device_key"`
Timestamp int64 `json:"timestamp"` // Unix milliseconds.
Duration uint64 `json:"duration"` // Milliseconds.
FPS int `json:"fps,omitempty"`
FileName string `json:"filename"`
DeviceKey string `json:"device_key"`
Timestamp int64 `json:"timestamp"` // Unix milliseconds.
Duration uint64 `json:"duration"` // Milliseconds.
FPS float64 `json:"fps,omitempty"`
}
// RecordingUploadMetadataFileName returns the queue marker name associated