Compare commits

...

13 Commits

Author SHA1 Message Date
Cédric Verstraeten
02d60c71e4 Merge pull request #289 from kerberos-io/fix/dynamic-gopsizes
fix/dynamic-gopsizes
2026-06-15 15:00:47 +02:00
Cédric Verstraeten
52647d7f1d Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 14:57:07 +02:00
Cédric Verstraeten
e1fa7d9d7e Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 14:56:46 +02:00
Cédric Verstraeten
06e2694763 Improve seam detection and add analysis tools
Refine loop/restart (seam) detection and enhance the mp4 analysis tooling.

- mp4: Replace previous previous-interval-based seam heuristic with a safer approach that (1) tracks the running minimum keyframe interval (MinKeyframeGapMs) as the reference cadence and (2) requires the buffered GOP to be genuinely truncated before dropping it. This avoids false positives on variable-GOP (smart-codec) cameras. Added fields, logging updates, and helper methods: bufferedVideoCount, expectedGopFrames, bufferedVideoFrameDuration. LastKeyframeGapMs is now diagnostic only.

- cmd/mp4analyze: Add -from/-to flags and auto-select a detailed inspection window centred on the largest keyframe gap. Limit per-sample printing to that window (and anomalies), fix sync-sample bit detection, pass window to sliceHeaders, and add a compact SUMMARY health report with medians and checks. Added inspectWindow and median helpers.

- tests: Add mp4_variablegop_test.go to verify variable-GOP streams keep healthy short GOPs and that no frames are dropped by the improved seam logic.

These changes prevent healthy GOPs from being discarded on normal short GOPs that follow long static GOPs and add better diagnostics for debugging artifacts.
2026-06-15 14:44:05 +02:00
Cédric Verstraeten
c0971ca3b2 Merge pull request #288 from kerberos-io/feature/resumable-uploads-to-hub
feature/resumable-uploads-to-hub
2026-06-14 15:21:13 +02:00
Cédric Verstraeten
1a788ebe6c Potential fix for pull request finding 'Writable file handle closed without error handling'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-13 22:41:50 +02:00
Cédric Verstraeten
a1b4026b4b Remove duplicate uppercase filename entries from git index
Case-only renames had committed both Camera.go and camera.go (identical
blobs) under core.ignorecase=true, causing 'case-insensitive file name
collision' in go build on CI (case-sensitive checkout). Drop the 15 stale
uppercase index entries; the lowercase files are unchanged.
2026-06-13 20:34:41 +00:00
Cédric Verstraeten
9bc9825bb1 Normalize filenames; add tus hub resumable tests
Rename many Go source files to lower_snake_case (e.g. RTSPClient.go -> rtsp_client.go, Server.go -> server.go, etc.) to follow project naming conventions. Enhance machinery/src/cloud/tus_client_test.go: add encoding/base64 import, record incoming requests (recordedRequest + requests slice), provide requestsForMethod helper, add test helpers (testHubConfig, decodeTusMetadata) and two new tests (TestUploadHubResumable_HappyPath and TestUploadHubResumable_Unsupported) that validate hub resumable upload behavior and per-method auth/metadata. Update swag.sh to point to the renamed server.go entry file.
2026-06-13 22:23:53 +02:00
Cédric Verstraeten
e9d2afa228 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-13 12:23:49 +02:00
Cédric Verstraeten
4b0e0eae9c Trim HubURI when building tus upload URL
Construct the tus upload base URL by trimming any trailing slash from config.HubURI before appending tusUploadPath. This prevents double slashes in the resulting URL when concatenating the base URI and the upload path, avoiding potential request/endpoint errors.
2026-06-13 09:55:49 +02:00
Cédric Verstraeten
e0204e1949 Add tus resumable uploads and Hub support
Prefer and support tus resumable uploads for Kerberos Vault/Hub and fall back to legacy single-POST when not available. Extract runTusUpload and a tusHeaderFunc to share the create/head/patch/terminate state machine between direct vault and hub-proxied uploads. Update tusCreate/tusHead/tusPatch/tusTerminate to accept header injection, implement uploadVaultResumable and uploadHubResumable wrappers, and add setHubTusHeaders. Also update UploadKerberosHub to attempt resumable uploads first and log fallback behavior.
2026-06-13 09:55:15 +02:00
Cédric Verstraeten
3c2a0ce0cf Merge pull request #287 from kerberos-io/feature/add-tus-progress-for-resumable-uploads
feature/add-tus-progress-for-resumable-uploads
2026-06-12 13:33:47 +02:00
Cédric Verstraeten
a5def2ccd8 Log tus resumable upload progress
Add periodic progress logging for tus resumable uploads. Introduces tusProgressBucketPercent (10%) and helper functions tusProgressBucket and logTusUploadProgress to bucket progress into 10% increments, cap at 100%, and avoid repeated logs. Initializes loggedProgressBucket in uploadVaultResumable and calls logTusUploadProgress after each successful PATCH so upload progress is reported concisely (percent and byte offsets) without excessive noise.
2026-06-12 13:29:32 +02:00
25 changed files with 772 additions and 64 deletions

View File

@@ -1,19 +1,24 @@
package main
import (
"flag"
"fmt"
"os"
"sort"
"github.com/Eyevinn/mp4ff/avc"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: mp4analyze <file.mp4>")
fromFlag := flag.Int64("from", -1, "start of the detailed inspection window (track timescale units); default auto-detects the largest keyframe gap")
toFlag := flag.Int64("to", -1, "end of the detailed inspection window (track timescale units); default auto-detected")
flag.Parse()
if flag.NArg() < 1 {
fmt.Println("usage: mp4analyze [-from N] [-to N] <file.mp4>")
os.Exit(1)
}
f, err := os.Open(os.Args[1])
f, err := os.Open(flag.Arg(0))
if err != nil {
panic(err)
}
@@ -110,7 +115,7 @@ func main() {
tid := traf.Tfhd.TrackID
tfdt := traf.Tfdt.BaseMediaDecodeTime()
offset := uint64(0)
var keys []uint64 // keyframe offset-from-tfdt
var keys []uint64 // keyframe offset-from-tfdt
var durs []uint64
zeroDur := 0
nSamples := 0
@@ -157,16 +162,21 @@ func main() {
if i > 0 {
gap = int64(k) - int64(allKeyGlobal[i-1])
}
flag := ""
seam := ""
if i > 1 {
prevGap := int64(allKeyGlobal[i-1]) - int64(allKeyGlobal[i-2])
if gap > 0 && prevGap > 0 && gap*2 < prevGap {
flag = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
seam = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
}
}
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, flag)
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, seam)
}
// Choose the detailed-inspection window. By default centre it on the largest
// keyframe gap (the most likely artifact location); -from/-to override.
winLo, winHi := inspectWindow(allKeyGlobal, *fromFlag, *toFlag)
fmt.Printf("=== detailed inspection window: dts %d..%d ===\n", winLo, winHi)
// Full sample timeline: DTS, CTS (=DTS+cto), composition offset, NAL types,
// to detect PTS non-monotonicity / gaps / param-set changes at the seam.
fmt.Println("=== per-sample timeline (full) — checking PTS monotonicity & nal types ===")
@@ -201,9 +211,11 @@ func main() {
if lastDTS >= 0 && dts < lastDTS {
anomaly += fmt.Sprintf(" <== DTS BACKWARDS (prev=%d)", lastDTS)
}
isSync := s.Flags&0x02000000 == 0 && (s.Flags>>24)&0x03 == 0x02
// Only print near the seam region and any anomalies, to keep output small.
near := dts >= 7800 && dts <= 8700
// sample_is_non_sync_sample is bit 16 (0x00010000); a sync sample
// has it clear and sample_depends_on==2 (i.e. an I-frame).
isSync := s.Flags&0x00010000 == 0 && (s.Flags>>24)&0x03 == 0x02
// Only print inside the inspection window and any anomalies, to keep output small.
near := dts >= winLo && dts <= winHi
if near || anomaly != "" {
fmt.Printf(" s%04d frag%d dts=%d cts=%d cto=%d dur=%d size=%d sync=%v nal=%v%s\n",
sampIdx, fragIdx, dts, cts, s.CompositionTimeOffset, s.Dur, len(s.Data), isSync, nals, anomaly)
@@ -293,10 +305,12 @@ func main() {
}
}
sliceHeaders(parsed, trex)
sliceHeaders(parsed, trex, winLo, winHi)
summary(parsed, trex)
}
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox, winLo, winHi int64) {
// Build SPS/PPS maps from avcC.
spsMap := map[uint32]*avc.SPS{}
ppsMap := map[uint32]*avc.PPS{}
@@ -322,7 +336,7 @@ func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
}
}
fmt.Println("=== slice headers near seam (frame_num / poc / idr_pic_id) ===")
fmt.Println("=== slice headers in inspection window (frame_num / poc / idr_pic_id) ===")
fragIdx := 0
sampIdx := 0
for _, seg := range parsed.Segments {
@@ -334,7 +348,7 @@ func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
}
for _, s := range fs {
dts := int64(s.DecodeTime)
if dts < 6800 || dts > 9400 {
if dts < winLo || dts > winHi {
sampIdx++
continue
}
@@ -423,3 +437,200 @@ func nalsByType(b []byte, want int) [][]byte {
}
return out
}
// inspectWindow returns the [lo,hi] decode-time range (track timescale units)
// for which sample-level detail is printed. Explicit -from/-to win; otherwise
// the window auto-centres on the largest gap between consecutive video
// keyframes — the most likely location of a visible artifact — with a margin on
// each side so the frames leading into and out of the gap are shown too.
func inspectWindow(keyDecodeTimes []uint64, from, to int64) (int64, int64) {
if from >= 0 || to >= 0 {
if from < 0 {
from = 0
}
if to < 0 {
to = from + 2000
}
return from, to
}
if len(keyDecodeTimes) < 2 {
return 0, 1 << 62
}
worstIdx, worstGap := 1, uint64(0)
for i := 1; i < len(keyDecodeTimes); i++ {
if g := keyDecodeTimes[i] - keyDecodeTimes[i-1]; g > worstGap {
worstGap = g
worstIdx = i
}
}
const margin = 500
lo := int64(keyDecodeTimes[worstIdx-1]) - margin
if lo < 0 {
lo = 0
}
return lo, int64(keyDecodeTimes[worstIdx]) + margin
}
// summary prints a compact, generic health report so a recording can be
// validated at a glance without reading the full per-sample dump above.
func summary(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
fmt.Println("=== SUMMARY (health checks) ===")
videoTracks, audioTracks := 0, 0
var videoTimescale uint64 = 1
if parsed.Init != nil && parsed.Init.Moov != nil {
for _, trak := range parsed.Init.Moov.Traks {
switch trak.Mdia.Hdlr.HandlerType {
case "vide":
videoTracks++
if trak.Mdia.Mdhd.Timescale != 0 {
videoTimescale = uint64(trak.Mdia.Mdhd.Timescale)
}
case "soun":
audioTracks++
}
}
}
fmt.Printf(" tracks: %d video, %d audio\n", videoTracks, audioTracks)
if audioTracks == 0 {
fmt.Println(" note: no audio track is embedded in this file")
}
type fragStat struct {
idx int
tfdt uint64
dur uint64
nSamp int
nKeys int
zeroDur int
fps float64
}
var stats []fragStat
var keyTimes []uint64
var fpsArr []float64
tfdtGaps := 0
var prevEnd uint64
havePrev := false
fi := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
st := fragStat{idx: fi, tfdt: traf.Tfdt.BaseMediaDecodeTime()}
off := uint64(0)
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
st.nSamp++
if (s.Flags>>24)&0x03 == 0x02 {
st.nKeys++
keyTimes = append(keyTimes, st.tfdt+off)
}
if s.Dur == 0 {
st.zeroDur++
}
off += uint64(s.Dur)
}
}
st.dur = off
d := st.dur
if d == 0 {
d = 1
}
st.fps = float64(st.nSamp) * float64(videoTimescale) / float64(d)
fpsArr = append(fpsArr, st.fps)
if havePrev && st.tfdt != prevEnd {
tfdtGaps++
}
prevEnd = st.tfdt + st.dur
havePrev = true
stats = append(stats, st)
}
fi++
}
}
medFps := medianFloat(fpsArr)
fmt.Printf(" fragments: %d (video timescale=%d, median %.1f fps)\n", len(stats), videoTimescale, medFps)
lowFps := 0
totalZero := 0
for _, st := range stats {
totalZero += st.zeroDur
flagStr := ""
if medFps > 0 && st.fps < medFps*0.9 {
lowFps++
flagStr = " <== LOW FRAME RATE — likely dropped frames"
}
fmt.Printf(" frag%02d tfdt=%-6d dur=%-5d samples=%-3d keyframes=%d zeroDur=%d fps=%.1f%s\n",
st.idx, st.tfdt, st.dur, st.nSamp, st.nKeys, st.zeroDur, st.fps, flagStr)
}
var gaps []uint64
for i := 1; i < len(keyTimes); i++ {
gaps = append(gaps, keyTimes[i]-keyTimes[i-1])
}
irregular := 0
if len(gaps) > 0 {
med := medianUint(gaps)
mn, mx := gaps[0], gaps[0]
for _, g := range gaps {
if g < mn {
mn = g
}
if g > mx {
mx = g
}
// Flag intervals that deviate by more than ~50% from the median GOP.
if med > 0 && (g*2 > med*3 || g*2 < med) {
irregular++
}
}
fmt.Printf(" keyframe gaps: min=%d median=%d max=%d irregular=%d/%d\n", mn, med, mx, irregular, len(gaps))
}
fmt.Printf(" tfdt discontinuities: %d\n", tfdtGaps)
fmt.Printf(" zero-duration samples: %d\n", totalZero)
fmt.Println(" verdict:")
clean := true
if audioTracks == 0 {
fmt.Println(" - no audio track (expected if this recording is video-only)")
}
if lowFps > 0 {
clean = false
fmt.Printf(" - %d fragment(s) have a reduced frame rate (dropped frames) — likely source of the artifacts\n", lowFps)
}
if irregular > 0 {
clean = false
fmt.Printf(" - %d irregular keyframe interval(s)\n", irregular)
}
if tfdtGaps > 0 {
clean = false
fmt.Printf(" - %d timeline (tfdt) discontinuity(ies)\n", tfdtGaps)
}
if totalZero > 0 {
clean = false
fmt.Printf(" - %d zero-duration sample(s)\n", totalZero)
}
if clean {
fmt.Println(" - container structure looks healthy")
}
}
func medianUint(v []uint64) uint64 {
if len(v) == 0 {
return 0
}
c := append([]uint64(nil), v...)
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
return c[len(c)/2]
}
func medianFloat(v []float64) float64 {
if len(v) == 0 {
return 0
}
c := append([]float64(nil), v...)
sort.Float64s(c)
return c[len(c)/2]
}

View File

@@ -46,7 +46,11 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
if file != nil {
defer file.Close()
defer func() {
if cerr := file.Close(); cerr != nil {
log.Log.Error("UploadDropbox: Error closing file: " + cerr.Error())
}
}()
}
if err == nil {

View File

@@ -34,6 +34,29 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
log.Log.Info("UploadKerberosHub: Uploading to Kerberos Hub (" + config.HubURI + ")")
log.Log.Info("UploadKerberosHub: Upload started for " + fileName)
// Prefer the resumable (tus) upload when enabled (the default). Kerberos Hub
// authenticates the agent with its Hub public/private key and proxies the
// resumable upload to the Kerberos Vault. When Hub does not expose a tus
// endpoint (older deployments) we transparently fall back to the legacy
// single-POST upload below.
if resumableUploadsEnabled() {
uploaded, _, supported, body, rerr := uploadHubResumable(&config, fileName, "UploadKerberosHub", "hub")
if supported {
if uploaded {
log.Log.Info("UploadKerberosHub: Upload Finished (resumable), " + body)
return true, true, nil
}
if rerr != nil {
log.Log.Info("UploadKerberosHub: resumable upload failed, " + rerr.Error())
} else {
log.Log.Info("UploadKerberosHub: resumable upload incomplete, " + body)
}
return false, true, rerr
}
log.Log.Info("UploadKerberosHub: resumable (tus) endpoint not available, falling back to legacy upload")
}
fullname := "data/recordings/" + fileName
// Check if we still have the file otherwise we abort the request.

View File

@@ -48,6 +48,8 @@ func resumableUploadsEnabled() bool {
// progress frequently, so an interruption resumes with minimal re-upload.
const tusDefaultChunkSize int64 = 1 << 20 // 1 MiB
const tusProgressBucketPercent int64 = 10
// tusChunkSize returns the number of bytes to send per PATCH request. It
// defaults to tusDefaultChunkSize (1 MiB) and can be overridden with the
// AGENT_TUS_CHUNK_SIZE_BYTES environment variable. A value of 0 (or negative)
@@ -67,17 +69,50 @@ func tusChunkSize() int64 {
return n
}
// uploadVaultResumable uploads a recording to a Kerberos Vault using the tus
// resumable upload protocol.
func tusProgressBucket(offset, size int64) int64 {
if size <= 0 {
return 100
}
percent := (offset * 100) / size
if percent > 100 {
percent = 100
}
return percent / tusProgressBucketPercent
}
func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64) {
bucket := tusProgressBucket(offset, size)
if bucket <= *loggedBucket {
return
}
*loggedBucket = bucket
percent := bucket * tusProgressBucketPercent
if percent > 100 {
percent = 100
}
log.Log.Infof("%s: resumable upload progress %d%% (%d/%d bytes)", label, percent, offset, size)
}
// tusHeaderFunc sets the authentication and routing headers required on every
// tus request for a particular upload target (Kerberos Vault directly, or
// Kerberos Hub which proxies to a vault). fileName is only meaningful on the
// creation request; it is empty on HEAD/PATCH/DELETE.
type tusHeaderFunc func(h http.Header, fileName string)
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
// to baseURL, sending target-specific authentication/routing headers via
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
// upload paths.
//
// Return values:
// - uploaded: the recording was fully received and persisted by the vault.
// - responded: the vault returned a definitive HTTP response (used by the
// - uploaded: the recording was fully received and persisted by the server.
// - responded: the server returned a definitive HTTP response (used by the
// caller to advance its retry/secondary-failover policy).
// - supported: the vault exposes a tus endpoint. When false, the caller should
// fall back to the legacy single-POST upload (older vault deployments).
// - supported: the server exposes a tus endpoint. When false, the caller
// should fall back to the legacy single-POST upload (older deployments).
// - body: a short message for logging.
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (uploaded bool, responded bool, supported bool, body string, err error) {
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
fullname := "data/recordings/" + fileName
file, ferr := os.Open(fullname)
@@ -98,17 +133,20 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
}
size := info.Size()
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
client := newVaultHTTPClient(0)
metadata := encodeTusMetadata(map[string]string{
"filename": fileName,
"device": deviceKey,
"directory": vault.Directory,
"provider": vault.Provider,
"capture": "IPCamera",
"cloudkey": publicKey,
})
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
for k := range req.Header {
if strings.HasPrefix(http.CanonicalHeaderKey(k), "X-Kerberos-") {
req.Header.Del(k)
}
}
}
return nil
}
sidecar := tusSidecarPath(fileName, slot)
uploadURL := loadTusResumeState(sidecar, baseURL)
@@ -119,7 +157,7 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
for attempt := 0; attempt < maxAttempts; attempt++ {
// (1) Ensure we have an active upload URL, creating one if needed.
if uploadURL == "" {
created, status, cerr := tusCreate(client, baseURL, size, metadata, vault, publicKey, deviceKey, fileName)
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
if cerr != nil {
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
// The vault does not implement tus; let the caller fall back.
@@ -134,7 +172,7 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
}
// (2) Query the current server-side offset.
offset, status, herr := tusHead(client, uploadURL, vault, publicKey, deviceKey)
offset, status, herr := tusHead(client, uploadURL, setHeaders)
if herr != nil {
if status == http.StatusNotFound || status == http.StatusGone {
// The upload expired/was removed server-side; start over.
@@ -154,7 +192,7 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
if restartedAfterComplete {
return false, true, true, "resumable finalize did not complete", errors.New(label + ": resumable finalize did not complete")
}
tusTerminate(client, uploadURL, vault, publicKey, deviceKey)
tusTerminate(client, uploadURL, setHeaders)
removeTusResumeState(sidecar)
uploadURL = ""
restartedAfterComplete = true
@@ -170,6 +208,7 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
progressed := false
patchFailed := false
var lastBody string
loggedProgressBucket := tusProgressBucket(offset, size)
for offset < size {
// Re-seek every chunk so the on-disk position always matches the
// server-acknowledged offset, even if a PATCH was partially accepted.
@@ -180,7 +219,7 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
if chunkSize > 0 && chunkSize < patchLen {
patchLen = chunkSize
}
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, vault, publicKey, deviceKey)
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
if perr != nil {
if status >= 400 {
// Definitive rejection (e.g. provider push failed during finalize).
@@ -198,6 +237,7 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
}
offset = newOffset
lastBody = respBody
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
if offset < size {
// Partial progress: persist so a later retry resumes from here.
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
@@ -221,9 +261,47 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
return false, true, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
}
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
// the tus resumable upload protocol. Credentials travel in the
// X-Kerberos-Storage-* headers on every request and routing (directory/provider)
// is additionally carried in the tus Upload-Metadata.
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, bool, string, error) {
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
metadata := encodeTusMetadata(map[string]string{
"filename": fileName,
"device": deviceKey,
"directory": vault.Directory,
"provider": vault.Provider,
"capture": "IPCamera",
"cloudkey": publicKey,
})
setHeaders := func(h http.Header, fn string) {
setVaultTusHeaders(h, vault, publicKey, deviceKey, fn)
}
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
}
// uploadHubResumable uploads a recording to Kerberos Hub's tus endpoint, which
// authenticates the agent with its Hub public/private key and proxies the
// resumable upload to the Kerberos Vault on the agent's behalf. The vault
// directory and provider are resolved and injected by Kerberos Hub, so they are
// intentionally omitted from the metadata here.
func uploadHubResumable(config *models.Config, fileName, label, slot string) (bool, bool, bool, string, error) {
baseURL := strings.TrimRight(config.HubURI, "/") + tusUploadPath
metadata := encodeTusMetadata(map[string]string{
"filename": fileName,
"device": config.Key,
"capture": "IPCamera",
})
setHeaders := func(h http.Header, fn string) {
setHubTusHeaders(h, config, fn)
}
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
}
// tusCreate performs the tus "creation" request (POST). On success it returns
// the resolved upload URL the agent should use for subsequent HEAD/PATCH calls.
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, vault models.KStorage, publicKey, deviceKey, fileName string) (string, int, error) {
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, setHeaders tusHeaderFunc, fileName string) (string, int, error) {
req, err := http.NewRequest("POST", baseURL, nil)
if err != nil {
return "", 0, err
@@ -233,7 +311,7 @@ func tusCreate(client *http.Client, baseURL string, size int64, metadata string,
if metadata != "" {
req.Header.Set("Upload-Metadata", metadata)
}
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, fileName)
setHeaders(req.Header, fileName)
resp, err := client.Do(req)
if resp != nil {
@@ -256,13 +334,13 @@ func tusCreate(client *http.Client, baseURL string, size int64, metadata string,
// tusHead performs the tus "offset" request (HEAD) and returns the current
// server-side upload offset.
func tusHead(client *http.Client, uploadURL string, vault models.KStorage, publicKey, deviceKey string) (int64, int, error) {
func tusHead(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) (int64, int, error) {
req, err := http.NewRequest("HEAD", uploadURL, nil)
if err != nil {
return 0, 0, err
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, "")
setHeaders(req.Header, "")
resp, err := client.Do(req)
if resp != nil {
@@ -287,7 +365,7 @@ func tusHead(client *http.Client, uploadURL string, vault models.KStorage, publi
// tusPatch streams up to length bytes of the file (starting at offset) to the
// upload URL using a single PATCH request. The body is read straight from the
// *os.File, so the recording is never fully buffered in memory.
func tusPatch(client *http.Client, uploadURL string, offset, length int64, file io.Reader, vault models.KStorage, publicKey, deviceKey string) (int64, int, string, error) {
func tusPatch(client *http.Client, uploadURL string, offset, length int64, file io.Reader, setHeaders tusHeaderFunc) (int64, int, string, error) {
req, err := http.NewRequest("PATCH", uploadURL, io.LimitReader(file, length))
if err != nil {
return offset, 0, "", err
@@ -296,7 +374,7 @@ func tusPatch(client *http.Client, uploadURL string, offset, length int64, file
req.Header.Set("Tus-Resumable", tusResumableVersion)
req.Header.Set("Content-Type", "application/offset+octet-stream")
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, "")
setHeaders(req.Header, "")
resp, err := client.Do(req)
if resp != nil {
@@ -321,13 +399,13 @@ func tusPatch(client *http.Client, uploadURL string, offset, length int64, file
}
// tusTerminate best-effort deletes an upload server-side (DELETE).
func tusTerminate(client *http.Client, uploadURL string, vault models.KStorage, publicKey, deviceKey string) {
func tusTerminate(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) {
req, err := http.NewRequest("DELETE", uploadURL, nil)
if err != nil {
return
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, "")
setHeaders(req.Header, "")
resp, derr := client.Do(req)
if resp != nil {
@@ -355,6 +433,22 @@ func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceK
}
}
// setHubTusHeaders sets the Kerberos Hub authentication headers on every tus
// request of a hub-proxied resumable upload. The agent authenticates with its
// Hub public/private key (exactly as the legacy single-POST hub upload does);
// Kerberos Hub validates the subscription and injects the vault credentials and
// directory/provider on the agent's behalf.
func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
h.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
h.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
h.Set("X-Kerberos-Hub-Region", config.S3.Region)
h.Set("X-Kerberos-Storage-Device", config.Key)
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
if fileName != "" {
h.Set("X-Kerberos-Storage-FileName", fileName)
}
}
// encodeTusMetadata serializes a map into the tus Upload-Metadata header format:
// a comma separated list of "key base64(value)" pairs. Keys are sorted for a
// deterministic header value. Empty values are skipped.

View File

@@ -2,6 +2,7 @@ package cloud
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net/http"
@@ -22,6 +23,13 @@ type fakeUpload struct {
offset int64
}
// recordedRequest captures the method and headers of a request received by the
// fake tus server, so tests can assert the client's per-method auth headers.
type recordedRequest struct {
method string
header http.Header
}
// fakeTus is a tiny in-memory implementation of the tus 1.0.0 server protocol,
// sufficient to exercise the agent's resumable client.
type fakeTus struct {
@@ -38,6 +46,10 @@ type fakeTus struct {
// failFinalize causes the next N completing PATCH requests to return 502
// after storing the bytes, simulating a failed completion hook.
failFinalize int
// requests records the headers of every received request (in order) so
// tests can assert which auth/routing headers the client sent per method.
requests []recordedRequest
}
func newFakeTus() *fakeTus {
@@ -84,10 +96,27 @@ func (s *fakeTus) createCount() int {
return s.creates
}
// requestsForMethod returns the recorded requests for the given HTTP method.
func (s *fakeTus) requestsForMethod(method string) []recordedRequest {
s.mu.Lock()
defer s.mu.Unlock()
var out []recordedRequest
for _, req := range s.requests {
if req.method == method {
out = append(out, req)
}
}
return out
}
func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, tusUploadPath)
w.Header().Set("Tus-Resumable", tusResumableVersion)
s.mu.Lock()
s.requests = append(s.requests, recordedRequest{method: r.Method, header: r.Header.Clone()})
s.mu.Unlock()
switch r.Method {
case http.MethodPost:
if s.unsupported {
@@ -378,6 +407,137 @@ func TestUploadVaultResumable_ResumeFromSidecar(t *testing.T) {
}
}
func testHubConfig(hubURI string) *models.Config {
return &models.Config{
Key: "device-key",
HubURI: hubURI,
HubKey: "hubpub",
HubPrivateKey: "hubpriv",
S3: &models.S3{Region: "eu-west"},
}
}
// decodeTusMetadata parses a tus Upload-Metadata header value ("key b64,key b64")
// back into a map of decoded key/value pairs.
func decodeTusMetadata(meta string) map[string]string {
out := map[string]string{}
if meta == "" {
return out
}
for _, pair := range strings.Split(meta, ",") {
parts := strings.SplitN(strings.TrimSpace(pair), " ", 2)
if parts[0] == "" {
continue
}
val := ""
if len(parts) == 2 {
if b, err := base64.StdEncoding.DecodeString(parts[1]); err == nil {
val = string(b)
}
}
out[parts[0]] = val
}
return out
}
func TestUploadHubResumable_HappyPath(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("h"), 4096)
withRecording(t, fileName, payload)
uploaded, _, supported, _, err := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("uploaded/supported = %v/%v, want both true", uploaded, supported)
}
if got := srv.totalBytes(); got != int64(len(payload)) {
t.Fatalf("server received %d bytes, want %d", got, len(payload))
}
// The Hub auth headers must be present on every request type (POST/HEAD/PATCH),
// because Kerberos Hub validates them on each proxied request. Conversely the
// vault credentials/routing are injected by Kerberos Hub on the agent's behalf
// and must never be sent by the agent on the hub path.
for _, method := range []string{http.MethodPost, http.MethodHead, http.MethodPatch} {
reqs := srv.requestsForMethod(method)
if len(reqs) == 0 {
t.Fatalf("expected at least one %s request", method)
}
for _, req := range reqs {
if got := req.header.Get("X-Kerberos-Hub-PublicKey"); got != "hubpub" {
t.Errorf("%s: X-Kerberos-Hub-PublicKey = %q, want %q", method, got, "hubpub")
}
if got := req.header.Get("X-Kerberos-Hub-PrivateKey"); got != "hubpriv" {
t.Errorf("%s: X-Kerberos-Hub-PrivateKey = %q, want %q", method, got, "hubpriv")
}
if got := req.header.Get("X-Kerberos-Hub-Region"); got != "eu-west" {
t.Errorf("%s: X-Kerberos-Hub-Region = %q, want %q", method, got, "eu-west")
}
if got := req.header.Get("X-Kerberos-Storage-Device"); got != "device-key" {
t.Errorf("%s: X-Kerberos-Storage-Device = %q, want %q", method, got, "device-key")
}
for _, h := range []string{
"X-Kerberos-Storage-AccessKey",
"X-Kerberos-Storage-SecretAccessKey",
"X-Kerberos-Storage-CloudKey",
"X-Kerberos-Storage-Provider",
"X-Kerberos-Storage-Directory",
} {
if got := req.header.Get(h); got != "" {
t.Errorf("%s: %s should be empty on the hub path, got %q", method, h, got)
}
}
}
}
// The creation request carries the upload metadata; on the hub path it must
// omit directory/provider/cloudkey (Hub resolves those) but include
// filename/device/capture. The filename header is also set on create.
posts := srv.requestsForMethod(http.MethodPost)
if got := posts[0].header.Get("X-Kerberos-Storage-FileName"); got != fileName {
t.Errorf("POST X-Kerberos-Storage-FileName = %q, want %q", got, fileName)
}
meta := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
for _, omitted := range []string{"directory", "provider", "cloudkey"} {
if _, ok := meta[omitted]; ok {
t.Errorf("hub metadata must omit %q, got %v", omitted, meta)
}
}
if meta["filename"] != fileName {
t.Errorf("hub metadata filename = %q, want %q", meta["filename"], fileName)
}
if meta["device"] != "device-key" {
t.Errorf("hub metadata device = %q, want %q", meta["device"], "device-key")
}
if meta["capture"] != "IPCamera" {
t.Errorf("hub metadata capture = %q, want %q", meta["capture"], "IPCamera")
}
}
func TestUploadHubResumable_Unsupported(t *testing.T) {
srv := newFakeTus()
srv.unsupported = true
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "f.mp4"
withRecording(t, fileName, []byte("hello"))
uploaded, _, supported, _, _ := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
if uploaded {
t.Fatal("expected uploaded=false against a hub without a tus endpoint")
}
if supported {
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
}
}
func TestEncodeTusMetadata(t *testing.T) {
got := encodeTusMetadata(map[string]string{
"b": "2",

View File

@@ -33,13 +33,18 @@ const MacEpochOffset uint64 = 2082844800
const FragmentDurationMs = 3000
// SeamGapDivisor controls loop-seam detection. A keyframe is treated as an
// upstream loop/restart seam when it arrives in less than (previous keyframe
// interval / SeamGapDivisor) — i.e. far sooner than the established keyframe
// cadence. Comparing against the *previous* interval (rather than a fixed
// millisecond threshold) makes the check scale automatically with the camera's
// configured GOP size: it works the same whether keyframes are 0.5s, 1s, 2s or
// more apart, and does not misfire on legitimately short-GOP or all-intra
// streams (where every interval is similar, so none looks anomalously short).
// upstream loop/restart seam when it arrives in less than (smallest normal
// keyframe interval / SeamGapDivisor) — i.e. far sooner than the camera's
// tightest established keyframe cadence.
//
// The reference is the running *minimum* keyframe interval, NOT the immediately
// preceding one. Variable-GOP ("smart codec") cameras lengthen the GOP during
// static scenes and shorten it again on motion, so consecutive intervals differ
// wildly (e.g. 2000 ms then 500 ms). Comparing against the previous interval
// then flags every normal short GOP that happens to follow a long static GOP as
// a seam and drops healthy video. Comparing against the minimum cadence instead
// scales with any configured GOP size (0.5s, 1s, 2s, ...) yet never mistakes the
// camera's own normal cadence for a premature seam IDR.
const SeamGapDivisor = 2
type MP4 struct {
@@ -85,7 +90,8 @@ type MP4 struct {
FragmentKeyframeCount int // Keyframes in the current fragment
PendingSampleIsKeyframe bool // Whether the pending video sample is a keyframe
LastKeyframeRawPTS uint64 // Raw PTS of the most recently seen keyframe (across fragments)
LastKeyframeGapMs uint64 // Interval (ms) between the two most recent keyframes; reference cadence for seam detection
LastKeyframeGapMs uint64 // Interval (ms) between the two most recent keyframes (diagnostic only)
MinKeyframeGapMs uint64 // Smallest keyframe interval (ms) seen so far; the camera's tightest cadence and the reference for seam detection
gopBuffer []bufferedSample // Current, not-yet-committed GOP (video frames + interleaved audio), held so a loop-seam GOP can be dropped before it reaches the file
}
@@ -333,23 +339,40 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
// buffered GOP is genuine (commit it) or the truncated tail GOP at an upstream
// loop/restart seam (drop it).
//
// The GOP size is configurable per camera, so we do NOT compare against a
// fixed millisecond threshold. Instead we compare this keyframe interval to
// the previous one and only flag a *sudden* shortening: a seam IDR arrives in
// less than (previous interval / SeamGapDivisor). Deriving the threshold from
// the observed cadence keeps detection correct for any configured GOP (0.5s,
// 1s, 2s, ...) and avoids false positives on steady short-GOP / all-intra
// streams (where consecutive intervals are similar, so none looks anomalously
// short). Because the reference is the immediately preceding interval, a burst
// of close keyframes only drops a single GOP instead of cascading.
// A genuine loop/restart seam has TWO signatures that must BOTH hold; we never
// drop a GOP on the interval alone, because variable-GOP ("smart codec")
// cameras legitimately shorten the GOP on motion:
//
// 1. The new keyframe arrives much sooner than the camera's tightest normal
// cadence: gap*SeamGapDivisor < MinKeyframeGapMs (the running MINIMUM
// interval). Using the minimum — not the previous interval — means a
// normal short GOP that merely follows a long static GOP (2000 ms -> 500 ms)
// is NOT flagged, while a true premature restart still is.
// 2. The GOP we just buffered is actually TRUNCATED — far shorter than a full
// GOP. A real seam cuts a GOP off mid-stream, leaving only a handful of
// frames; a healthy GOP (even a legitimately short one) is left intact and
// must be committed in full. We require the buffered tail to be under half
// the minimum normal GOP length to qualify as truncated.
//
// Deriving both thresholds from the observed cadence keeps detection correct
// for any configured GOP size (0.5s, 1s, 2s, ...) and stops the heuristic from
// discarding healthy video.
seam := false
if mp4.LastKeyframeRawPTS > 0 && pts > mp4.LastKeyframeRawPTS {
gap := pts - mp4.LastKeyframeRawPTS
if mp4.LastKeyframeGapMs > 0 && gap*SeamGapDivisor < mp4.LastKeyframeGapMs {
bufferedVideo := mp4.bufferedVideoCount()
// Frames a full GOP at the tightest normal cadence would contain.
fullGopFrames := mp4.expectedGopFrames(gap)
closeKeyframe := mp4.MinKeyframeGapMs > 0 && gap*SeamGapDivisor < mp4.MinKeyframeGapMs
truncatedTail := fullGopFrames > 0 && bufferedVideo*2 < fullGopFrames
if closeKeyframe && truncatedTail {
seam = true
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): dropping truncated GOP at unexpectedly close keyframe (interval=%d ms, previous interval=%d ms, buffered samples=%d) - likely upstream loop/restart discontinuity", gap, mp4.LastKeyframeGapMs, len(mp4.gopBuffer)))
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): dropping truncated GOP at premature keyframe (interval=%d ms, min interval=%d ms, buffered video frames=%d of ~%d) - likely upstream loop/restart discontinuity", gap, mp4.MinKeyframeGapMs, bufferedVideo, fullGopFrames))
}
mp4.LastKeyframeGapMs = gap
if !seam && (mp4.MinKeyframeGapMs == 0 || gap < mp4.MinKeyframeGapMs) {
mp4.MinKeyframeGapMs = gap
}
}
mp4.LastKeyframeRawPTS = pts
@@ -372,6 +395,70 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
return nil
}
// bufferedVideoCount returns how many video-track samples are currently held in
// the GOP buffer (interleaved audio samples are ignored). It measures how
// complete the buffered GOP is, used to tell a truncated seam tail from a
// healthy — possibly legitimately short — GOP.
func (mp4 *MP4) bufferedVideoCount() uint64 {
var n uint64
for _, s := range mp4.gopBuffer {
if s.trackID == uint32(mp4.VideoTrack) {
n++
}
}
return n
}
// expectedGopFrames estimates how many video frames a full GOP at the camera's
// tightest normal cadence (MinKeyframeGapMs) would contain, using the video
// frame interval inferred from the buffered GOP. gap is the current keyframe
// interval, used as a fallback frame-duration source. Returns 0 when there is
// not yet enough information to judge (so callers must not treat a GOP as
// truncated without a reliable estimate).
func (mp4 *MP4) expectedGopFrames(gap uint64) uint64 {
cadence := mp4.MinKeyframeGapMs
if cadence == 0 {
return 0
}
frameDur := mp4.bufferedVideoFrameDuration()
if frameDur == 0 {
// Fall back to deriving a per-frame duration from the buffered tail across
// the current interval; if that is unavailable too, we cannot estimate.
if n := mp4.bufferedVideoCount(); n > 0 && gap > 0 {
frameDur = gap / n
}
}
if frameDur == 0 {
return 0
}
return cadence / frameDur
}
// bufferedVideoFrameDuration returns the average per-frame duration (in PTS
// units) of the video samples currently buffered, derived from the PTS deltas
// between consecutive video frames. Returns 0 when fewer than two video frames
// are buffered.
func (mp4 *MP4) bufferedVideoFrameDuration() uint64 {
var prev uint64
havePrev := false
var sum, count uint64
for _, s := range mp4.gopBuffer {
if s.trackID != uint32(mp4.VideoTrack) {
continue
}
if havePrev && s.pts > prev {
sum += s.pts - prev
count++
}
prev = s.pts
havePrev = true
}
if count == 0 {
return 0
}
return sum / count
}
// commitBufferedGOP writes every sample currently held in gopBuffer to the file
// in arrival order, then clears the buffer. Committing in arrival order
// preserves the original audio/video interleave and lets commitSampleToTrack's

View File

@@ -0,0 +1,129 @@
package video
import (
"os"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/models"
)
// TestMP4VariableGOPKeepsHealthyShortGOP reproduces the adam-drive regression:
// a variable-GOP ("smart codec") camera lengthens its keyframe interval during a
// static scene (e.g. 500ms -> 1500/2000ms) and then drops back to its normal
// 500ms cadence on motion. That normal, FULL 500ms GOP arrives much sooner than
// the immediately preceding (long, static) GOP.
//
// The previous heuristic compared the new keyframe interval against the *previous*
// interval and dropped the GOP whenever gap < previousInterval/2 — so every normal
// 500ms keyframe following a long static GOP was misclassified as a premature
// loop/restart seam and a whole healthy GOP (~15 frames) was discarded. In the
// field this silently deleted ~0.5s of video on virtually every recording from
// such cameras, producing a freeze/jump artifact.
//
// After the fix the seam check compares against the running MINIMUM cadence and
// additionally requires the buffered GOP to be genuinely truncated, so a full
// healthy GOP is always kept regardless of how long the preceding GOP was. This
// test asserts that NO frames are dropped for a pure variable-GOP stream.
func TestMP4VariableGOPKeepsHealthyShortGOP(t *testing.T) {
tmpFile, err := os.CreateTemp("", "test_variable_gop_*.mp4")
if err != nil {
t.Fatalf("create temp: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
pps := []byte{0x68, 0xce, 0x38, 0x80}
mp4Video := NewMP4(tmpFile.Name(), [][]byte{sps}, [][]byte{pps}, nil, 60)
mp4Video.SetWidth(1920)
mp4Video.SetHeight(1080)
v := mp4Video.AddVideoTrack("H264")
mk := func(k bool) []byte {
nt := byte(0x01)
if k {
nt = 0x65
}
f := []byte{0, 0, 0, 1, nt}
for i := 0; i < 200; i++ {
f = append(f, byte(i))
}
return f
}
const frameDur = uint64(33)
pts := uint64(0)
emitFrame := func(isKey bool) {
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts, 0)
pts += frameDur
}
// emitGOP emits a complete GOP of exactly frames frames: a leading keyframe
// followed by frames-1 P-frames. Every GOP here is healthy and complete; only
// its length varies, exactly as a smart-codec camera varies the GOP.
emitGOP := func(frames int) {
emitFrame(true)
for i := 0; i < frames-1; i++ {
emitFrame(false)
}
}
// Normal cadence is 15 frames (~500ms). The camera then lengthens the GOP for
// several static scenes (45 and 60 frames, ~1500ms and ~2000ms) before
// dropping back to the normal 15-frame GOP on motion — the transition the old
// heuristic wrongly treated as a seam. The whole sequence is then repeated to
// cover multiple long->short transitions.
gopLengths := []int{15, 15, 45, 15, 60, 15, 15, 45, 15, 15, 60, 15}
totalEmittedFrames := 0
emittedKeyframes := 0
for _, n := range gopLengths {
emitGOP(n)
totalEmittedFrames += n
emittedKeyframes++
}
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
f, err := os.Open(tmpFile.Name())
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
parsed, err := mp4ff.DecodeFile(f)
if err != nil {
t.Fatalf("decode: %v", err)
}
totalSamples := 0
totalSync := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
totalSamples++
// sample_depends_on == 2 => "does not depend on others" => IDR/sync.
if (s.Flags>>24)&0x03 == 0x02 {
totalSync++
}
}
}
}
}
}
// Every GOP is healthy, so nothing must be dropped: all keyframes and all
// frames must survive. A shortfall means a normal variable-GOP keyframe was
// misclassified as a seam.
if totalSync != emittedKeyframes {
t.Errorf("got %d keyframes in output, want %d - a healthy variable-GOP keyframe was wrongly dropped as a seam",
totalSync, emittedKeyframes)
}
if totalSamples != totalEmittedFrames {
t.Errorf("got %d video samples in output, want %d - a healthy variable-GOP GOP was wrongly dropped as a seam",
totalSamples, totalEmittedFrames)
}
}

View File

@@ -1,2 +1,2 @@
#!/bin/bash
swag init -g ./src/routers/http/Server.go
swag init -g ./src/routers/http/server.go