diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 92d79f0e..3aaa56bf 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -830,20 +830,20 @@ "name": "start", "in": "query", "required": false, - "description": "Start time in nanoseconds from the beginning of the video. Defaults to 0.", + "description": "Start time in milliseconds from the beginning of the video. Defaults to 0. For a place.stream.video record whose source is a place.stream.media.defs#sourceClip, this is in the clip's local timeline (0 == the clip's start).", "schema": { "type": "integer", - "description": "Start time in nanoseconds from the beginning of the video. Defaults to 0." + "description": "Start time in milliseconds from the beginning of the video. Defaults to 0. For a place.stream.video record whose source is a place.stream.media.defs#sourceClip, this is in the clip's local timeline (0 == the clip's start)." } }, { "name": "end", "in": "query", "required": false, - "description": "End time in nanoseconds. Omit to include all remaining content.", + "description": "End time in milliseconds. Omit to include all remaining content. Local to the clip's timeline when playing back a sourceClip record.", "schema": { "type": "integer", - "description": "End time in nanoseconds. Omit to include all remaining content." + "description": "End time in milliseconds. Omit to include all remaining content. Local to the clip's timeline when playing back a sourceClip record." } }, { diff --git a/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md index 3309e782..770d79d1 100644 --- a/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md +++ b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md @@ -21,8 +21,8 @@ Get an HLS CMAF playlist for a video. Returns a master playlist when `track` is | ------- | --------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | | `uri` | `string` | ✅ | AT-URI of the record to play back (e.g. at://did:plc:.../place.stream.video/). | Format: `at-uri` | | `track` | `string` | ❌ | Track ID (stringified u32 matching the MUXL container) for a single-track media playlist. Omit for the master playlist. | | -| `start` | `integer` | ❌ | Start time in nanoseconds from the beginning of the video. Defaults to 0. | | -| `end` | `integer` | ❌ | End time in nanoseconds. Omit to include all remaining content. | | +| `start` | `integer` | ❌ | Start time in milliseconds from the beginning of the video. Defaults to 0. For a place.stream.video record whose source is a place.stream.media.defs#sourceClip, this is in the clip's local timeline (0 == the clip's start). | | +| `end` | `integer` | ❌ | End time in milliseconds. Omit to include all remaining content. Local to the clip's timeline when playing back a sourceClip record. | | | `sid` | `string` | ❌ | Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting. | | **Output:** @@ -65,11 +65,11 @@ _Schema not defined._ }, "start": { "type": "integer", - "description": "Start time in nanoseconds from the beginning of the video. Defaults to 0." + "description": "Start time in milliseconds from the beginning of the video. Defaults to 0. For a place.stream.video record whose source is a place.stream.media.defs#sourceClip, this is in the clip's local timeline (0 == the clip's start)." }, "end": { "type": "integer", - "description": "End time in nanoseconds. Omit to include all remaining content." + "description": "End time in milliseconds. Omit to include all remaining content. Local to the clip's timeline when playing back a sourceClip record." }, "sid": { "type": "string", diff --git a/lexicons/place/stream/playback/getVideoPlaylist.json b/lexicons/place/stream/playback/getVideoPlaylist.json index c47097d0..4f278749 100644 --- a/lexicons/place/stream/playback/getVideoPlaylist.json +++ b/lexicons/place/stream/playback/getVideoPlaylist.json @@ -20,11 +20,11 @@ }, "start": { "type": "integer", - "description": "Start time in nanoseconds from the beginning of the video. Defaults to 0." + "description": "Start time in milliseconds from the beginning of the video. Defaults to 0. For a place.stream.video record whose source is a place.stream.media.defs#sourceClip, this is in the clip's local timeline (0 == the clip's start)." }, "end": { "type": "integer", - "description": "End time in nanoseconds. Omit to include all remaining content." + "description": "End time in milliseconds. Omit to include all remaining content. Local to the clip's timeline when playing back a sourceClip record." }, "sid": { "type": "string", diff --git a/pkg/spxrpc/place_stream_playback_getvideo.go b/pkg/spxrpc/place_stream_playback_getvideo.go index 1ad0349c..5522e77f 100644 --- a/pkg/spxrpc/place_stream_playback_getvideo.go +++ b/pkg/spxrpc/place_stream_playback_getvideo.go @@ -263,15 +263,15 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error { } track := c.QueryParam("track") - startNS, err := optionalInt64Param(c, "start") + startMS, err := optionalInt64Param(c, "start") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - endNS, err := optionalInt64Param(c, "end") + endMS, err := optionalInt64Param(c, "end") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - if startNS != nil && endNS != nil && *startNS >= *endNS { + if startMS != nil && endMS != nil && *startMS >= *endMS { return echo.NewHTTPError(http.StatusBadRequest, "start must be less than end") } @@ -310,11 +310,22 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error { return err } + // Compose query-param start/end with any record-level clip bounds. + // Query params live in the *clip's* local timeline (0 == start of + // the clip); filterSegments wants bounds in the parent video's + // timeline. For non-clip records clipStartMS == 0 and clipEndMS == + // nil, so the math collapses to identity. + effectiveStartMS, effectiveEndMS := composeClipBounds(resolved.clipStartMS, resolved.clipEndMS, startMS, endMS) + var body string if track == "" { - body = masterPlaylist(meta, uri, sid, startNS, endNS) + // Master playlist's sub-playlist URLs carry the *unmodified* + // query-param start/end (clip-local). Each per-track follow-up + // request re-resolves the clip and composes bounds again on the + // server side, so the propagation stays in clip-local time. + body = masterPlaylist(meta, uri, sid, startMS, endMS) } else { - body, err = mediaPlaylist(meta, track, ownerDID.String(), sid, s.cli.VODCDNURL, startNS, endNS) + body, err = mediaPlaylist(meta, track, ownerDID.String(), sid, s.cli.VODCDNURL, effectiveStartMS, effectiveEndMS) if err != nil { return err } @@ -341,18 +352,89 @@ func optionalInt64Param(c echo.Context, name string) (*int64, error) { return &n, nil } -// resolvedVideo carries the primary blob CID for a video record, plus -// any record-level clip bounds (place.stream.media.defs#sourceClip) -// once we add that to the resolution path. +// resolvedVideo carries everything HandleGetVideoPlaylist needs to +// emit a playlist for a given video URI: the primary blob CID to fetch +// the metafile from, and (for sourceClip records) the parent-timeline +// bounds the playback should be restricted to. +// +// For direct sourceTracks records both clip fields are zero values +// (clipEndMS == nil meaning "no upper bound"); for sourceClip records +// they carry the bounds the record names, in milliseconds against the +// parent video's timeline. type resolvedVideo struct { - blobCID string + blobCID string + clipStartMS int64 + clipEndMS *int64 } -// resolveVideoBlob walks video -> first track -> muxlTrack.blob using -// the local index. The metafile holds catalogs for every track of the -// blob, so picking any track's blob is sufficient — they all live in -// the same container. +// resolveVideoBlob loads the place.stream.video record at uri and walks +// it to a content blob the player can read segments out of. +// +// Two source shapes are supported, mirroring the union in the video +// lexicon: +// +// - place.stream.media.defs#sourceTracks: the canonical case. We +// grab the first track of the bundle and use its muxlTrack.blob +// as the primary blob CID; the metafile keyed at that CID +// catalogs every track in the same container. +// +// - place.stream.media.defs#sourceClip: the record is a "clip" of +// another video. We re-resolve the parent (which itself must be a +// plain sourceTracks record — we don't chain clips) and surface +// the clip's start/end millisecond bounds so the handler can +// restrict the emitted segments. func (s *Server) resolveVideoBlob(ctx context.Context, uri string) (*resolvedVideo, error) { + videoRec, err := s.loadVideoRecord(ctx, uri) + if err != nil { + return nil, err + } + + switch { + case videoRec.Source != nil && videoRec.Source.MediaDefs_SourceTracks != nil: + blobCID, err := s.firstTrackBlob(ctx, videoRec.Source.MediaDefs_SourceTracks) + if err != nil { + return nil, err + } + return &resolvedVideo{blobCID: blobCID}, nil + + case videoRec.Source != nil && videoRec.Source.MediaDefs_SourceClip != nil: + clip := videoRec.Source.MediaDefs_SourceClip + if clip.Video == "" { + return nil, echo.NewHTTPError(http.StatusUnprocessableEntity, "sourceClip missing parent video URI") + } + if clip.Start < 0 || clip.End <= clip.Start { + return nil, echo.NewHTTPError(http.StatusUnprocessableEntity, "sourceClip bounds must be 0 <= start < end") + } + parentRec, err := s.loadVideoRecord(ctx, clip.Video) + if err != nil { + return nil, err + } + // One level only — clip-of-clip is rejected to keep playback + // resolution bounded and predictable. + if parentRec.Source == nil || parentRec.Source.MediaDefs_SourceTracks == nil { + return nil, echo.NewHTTPError(http.StatusUnprocessableEntity, + "sourceClip's parent video must use sourceTracks (clip-of-clip is not supported)") + } + blobCID, err := s.firstTrackBlob(ctx, parentRec.Source.MediaDefs_SourceTracks) + if err != nil { + return nil, err + } + end := clip.End + return &resolvedVideo{ + blobCID: blobCID, + clipStartMS: clip.Start, + clipEndMS: &end, + }, nil + + default: + return nil, echo.NewHTTPError(http.StatusUnprocessableEntity, "video record has no playable source") + } +} + +// loadVideoRecord pulls the place.stream.video record at uri out of +// the local index and CBOR-decodes it. Returns a typed error suitable +// for surfacing back to the client (404, 422, 500). +func (s *Server) loadVideoRecord(ctx context.Context, uri string) (*streamplace.Video, error) { video, err := s.model.GetVideoByURI(ctx, uri) if err != nil { log.Error(ctx, "playback: GetVideoByURI failed", "uri", uri, "error", err) @@ -361,7 +443,6 @@ func (s *Server) resolveVideoBlob(ctx context.Context, uri string) (*resolvedVid if video == nil { return nil, echo.NewHTTPError(http.StatusNotFound, "VideoNotFound") } - rec, err := lexutil.CborDecodeValue(video.Record) if err != nil { log.Error(ctx, "playback: decode video record failed", "uri", uri, "error", err) @@ -372,23 +453,29 @@ func (s *Server) resolveVideoBlob(ctx context.Context, uri string) (*resolvedVid return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("video record at %s decoded as %T, expected *streamplace.Video", uri, rec)) } - if videoRec.Source == nil || videoRec.Source.MediaDefs_SourceTracks == nil || - len(videoRec.Source.MediaDefs_SourceTracks.Tracks) == 0 { - return nil, echo.NewHTTPError(http.StatusUnprocessableEntity, "video record has no tracks") + return videoRec, nil +} + +// firstTrackBlob returns the muxlTrack.blob CID of the first ref in a +// sourceTracks bundle. The metafile at that CID describes every track +// in the same container, so any track's blob is enough. +func (s *Server) firstTrackBlob(ctx context.Context, src *streamplace.MediaDefs_SourceTracks) (string, error) { + if len(src.Tracks) == 0 { + return "", echo.NewHTTPError(http.StatusUnprocessableEntity, "video record has no tracks") } - firstRef := videoRec.Source.MediaDefs_SourceTracks.Tracks[0] + firstRef := src.Tracks[0] if firstRef == nil || firstRef.Uri == "" { - return nil, echo.NewHTTPError(http.StatusUnprocessableEntity, "video record's first track ref is empty") + return "", echo.NewHTTPError(http.StatusUnprocessableEntity, "video record's first track ref is empty") } track, err := s.model.GetMediaTrackByURI(ctx, firstRef.Uri) if err != nil { log.Error(ctx, "playback: GetMediaTrackByURI failed", "uri", firstRef.Uri, "error", err) - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return "", echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if track == nil || track.Blob == "" { - return nil, echo.NewHTTPError(http.StatusNotFound, "TrackNotFound") + return "", echo.NewHTTPError(http.StatusNotFound, "TrackNotFound") } - return &resolvedVideo{blobCID: track.Blob}, nil + return track.Blob, nil } // fetchMetafile reads blobs/.json from the playback store and @@ -459,16 +546,16 @@ func blobURL(cdnURL, did, cid, sid string) string { // by this same handler. `sid` is propagated from the master playlist // so the player's media-playlist + segment requests all share an // identifier. -func trackPlaylistURL(uri, track, sid string, startNS, endNS *int64) string { +func trackPlaylistURL(uri, track, sid string, startMS, endMS *int64) string { q := url.Values{"uri": {uri}, "track": {track}} if sid != "" { q.Set("sid", sid) } - if startNS != nil { - q.Set("start", strconv.FormatInt(*startNS, 10)) + if startMS != nil { + q.Set("start", strconv.FormatInt(*startMS, 10)) } - if endNS != nil { - q.Set("end", strconv.FormatInt(*endNS, 10)) + if endMS != nil { + q.Set("end", strconv.FormatInt(*endMS, 10)) } return "/xrpc/place.stream.playback.getVideoPlaylist?" + q.Encode() } @@ -479,7 +566,7 @@ func trackPlaylistURL(uri, track, sid string, startNS, endNS *int64) string { // URLs so the player keeps the clip bounds. `sid` is threaded into // every sub-playlist URL so the per-track requests + downstream // segment fetches share one playback-session identifier. -func masterPlaylist(meta *vod.Metafile, uri, sid string, startNS, endNS *int64) string { +func masterPlaylist(meta *vod.Metafile, uri, sid string, startMS, endMS *int64) string { lines := []string{"#EXTM3U", "#EXT-X-VERSION:6", ""} // Collect audio tracks in deterministic order, pick a default @@ -494,7 +581,7 @@ func masterPlaylist(meta *vod.Metafile, uri, sid string, startNS, endNS *int64) t.Codec, yesNo(isDefault), fmt.Sprintf("%d", channelsOrDefault(t.Channels)), - trackPlaylistURL(uri, tid, sid, startNS, endNS), + trackPlaylistURL(uri, tid, sid, startMS, endMS), )) } if len(audioIDs) > 0 { @@ -526,7 +613,7 @@ func masterPlaylist(meta *vod.Metafile, uri, sid string, startNS, endNS *int64) streamInf += `,AUDIO="audio"` } lines = append(lines, streamInf) - lines = append(lines, trackPlaylistURL(uri, tid, sid, startNS, endNS)) + lines = append(lines, trackPlaylistURL(uri, tid, sid, startMS, endMS)) } return strings.Join(lines, "\n") + "\n" @@ -538,12 +625,12 @@ func masterPlaylist(meta *vod.Metafile, uri, sid string, startNS, endNS *int64) // threaded into every blob URL for egress accounting and `sid` for // view-count correlation against the request that built this playlist. // `cdnURL` is the configured CDN root, or "" for self-hosted serving. -func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID, sid, cdnURL string, startNS, endNS *int64) (string, error) { +func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID, sid, cdnURL string, startMS, endMS *int64) (string, error) { t, ok := meta.Tracks[trackID] if !ok { return "", echo.NewHTTPError(http.StatusNotFound, "TrackNotFound") } - segments := filterSegments(t.Segments, t.Timescale, startNS, endNS) + segments := filterSegments(t.Segments, t.Timescale, startMS, endMS) var maxDurSec float64 for _, s := range segments { @@ -583,22 +670,61 @@ func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID, sid, cdnURL string, st return strings.Join(lines, "\n") + "\n", nil } +// composeClipBounds maps clip-local (queryStart, queryEnd) bounds and +// record-level (clipStartMS, clipEndMS) bounds into the parent +// video's timeline that filterSegments operates on. Either side can +// be nil/zero to mean "no bound on this end": +// +// - clipStartMS == 0 + clipEndMS == nil: not a clip; the record is +// a plain sourceTracks video. The returned bounds are just the +// query params. +// +// - For a clip, query bounds are translated by adding clipStartMS; +// the end is clamped to clipEndMS so a player asking for more +// than the clip can't escape it. +func composeClipBounds(clipStartMS int64, clipEndMS, queryStartMS, queryEndMS *int64) (*int64, *int64) { + effStart := clipStartMS + if queryStartMS != nil { + effStart += *queryStartMS + } + + var effEnd *int64 + if queryEndMS != nil { + e := clipStartMS + *queryEndMS + if clipEndMS != nil && e > *clipEndMS { + e = *clipEndMS + } + effEnd = &e + } else if clipEndMS != nil { + e := *clipEndMS + effEnd = &e + } + + var startPtr *int64 + if effStart > 0 { + startPtr = &effStart + } + return startPtr, effEnd +} + // filterSegments returns the subset of segments that overlap -// [startNS, endNS). Segments are GOP-aligned, so any segment whose +// [startMS, endMS). Segments are GOP-aligned, so any segment whose // time span intersects the requested range is included in full — -// no sub-segment splitting. -func filterSegments(segments []vod.MetafileSegment, timescale uint32, startNS, endNS *int64) []vod.MetafileSegment { - if startNS == nil && endNS == nil { +// no sub-segment splitting. Bounds are in the parent video's +// timeline; clip-record local times must be composed by the caller +// before they get here. +func filterSegments(segments []vod.MetafileSegment, timescale uint32, startMS, endMS *int64) []vod.MetafileSegment { + if startMS == nil && endMS == nil { return segments } tsf := float64(timescale) startTicks := int64(0) endTicks := int64(1 << 62) - if startNS != nil { - startTicks = int64(float64(*startNS) / 1e9 * tsf) + if startMS != nil { + startTicks = int64(float64(*startMS) / 1e3 * tsf) } - if endNS != nil { - endTicks = int64(float64(*endNS) / 1e9 * tsf) + if endMS != nil { + endTicks = int64(float64(*endMS) / 1e3 * tsf) } out := segments[:0:0] diff --git a/pkg/spxrpc/place_stream_playback_getvideo_test.go b/pkg/spxrpc/place_stream_playback_getvideo_test.go index 341511fa..c9b7bba7 100644 --- a/pkg/spxrpc/place_stream_playback_getvideo_test.go +++ b/pkg/spxrpc/place_stream_playback_getvideo_test.go @@ -1,18 +1,24 @@ package spxrpc import ( + "bytes" + "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "time" + comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" "stream.place/streamplace/pkg/blob" + "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/spid" + "stream.place/streamplace/pkg/streamplace" "stream.place/streamplace/pkg/vod" ) @@ -92,10 +98,10 @@ func TestMasterPlaylist(t *testing.T) { } func TestMasterPlaylist_PropagatesTimeRange(t *testing.T) { - start, end := int64(1_000_000_000), int64(2_000_000_000) + start, end := int64(1_000), int64(2_000) // milliseconds pl := masterPlaylist(fixtureMetafile(), fixtureURI, fixtureSID, &start, &end) - require.Contains(t, pl, "start=1000000000") - require.Contains(t, pl, "end=2000000000") + require.Contains(t, pl, "start=1000") + require.Contains(t, pl, "end=2000") } func TestMediaPlaylist_Video(t *testing.T) { @@ -130,9 +136,9 @@ func TestMediaPlaylist_UnknownTrack(t *testing.T) { func TestMediaPlaylist_TimeRangeFilters(t *testing.T) { // Video track has two 1-second segments (timescale 6000, 6000 - // ticks each). Ask for [1s, 2s): should keep only segment 1. - start := int64(1_000_000_000) - end := int64(2_000_000_000) + // ticks each). Ask for [1000ms, 2000ms): should keep only segment 1. + start := int64(1_000) + end := int64(2_000) pl, err := mediaPlaylist(fixtureMetafile(), "1", fixtureDID, fixtureSID, "", &start, &end) require.NoError(t, err) require.NotContains(t, pl, `#EXT-X-BYTERANGE:2000@100`) @@ -234,6 +240,227 @@ func TestSessionIDOrNew(t *testing.T) { }) } +// videoRecordCBOR marshals a *streamplace.Video to CBOR for stuffing +// into model.Video.Record so resolveVideoBlob's decode path can +// round-trip it back. +func videoRecordCBOR(t *testing.T, v *streamplace.Video) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, v.MarshalCBOR(&buf)) + return buf.Bytes() +} + +// TestResolveVideoBlob_SourceClip walks the playback-resolve path for +// a clip record: the clip's `Video` field points at a parent video +// whose sourceTracks lead to a real MediaTrack + blob CID. resolve +// should return the parent's blob CID and surface the clip's bounds. +func TestResolveVideoBlob_SourceClip(t *testing.T) { + ctx := context.Background() + m, err := model.MakeDB(":memory:") + require.NoError(t, err) + s := &Server{model: m} + + const ( + owner = "did:plc:owner" + trackURI = "at://did:plc:owner/place.stream.media.track/parent-track" + parentURI = "at://did:plc:owner/place.stream.video/parent" + clipURI = "at://did:plc:owner/place.stream.video/clipof" + parentCID = "bafyparentblob" + ) + + require.NoError(t, m.UpsertMediaTrack(ctx, &model.MediaTrack{ + URI: trackURI, + RepoDID: owner, + Blob: parentCID, + TrackID: "1", + MediaType: "video", + })) + + parent := &streamplace.Video{ + LexiconTypeID: "place.stream.video", + Title: "parent", + Source: &streamplace.Video_Source{ + MediaDefs_SourceTracks: &streamplace.MediaDefs_SourceTracks{ + LexiconTypeID: "place.stream.media.defs#sourceTracks", + Tracks: []*comatproto.RepoStrongRef{ + {Uri: trackURI, Cid: "bafyrefcid"}, + }, + }, + }, + } + require.NoError(t, m.UpsertVideo(ctx, &model.Video{ + URI: parentURI, + CID: "bafyparentrec", + RepoDID: owner, + RKey: "parent", + Title: parent.Title, + Record: videoRecordCBOR(t, parent), + IndexedAt: time.Now().UTC(), + })) + + clip := &streamplace.Video{ + LexiconTypeID: "place.stream.video", + Title: "5s..10s of parent", + Source: &streamplace.Video_Source{ + MediaDefs_SourceClip: &streamplace.MediaDefs_SourceClip{ + LexiconTypeID: "place.stream.media.defs#sourceClip", + Video: parentURI, + Start: 5_000, + End: 10_000, + }, + }, + } + require.NoError(t, m.UpsertVideo(ctx, &model.Video{ + URI: clipURI, + CID: "bafycliprec", + RepoDID: owner, + RKey: "clipof", + Title: clip.Title, + Record: videoRecordCBOR(t, clip), + IndexedAt: time.Now().UTC(), + })) + + t.Run("non-clip parent resolves with no bounds", func(t *testing.T) { + got, err := s.resolveVideoBlob(ctx, parentURI) + require.NoError(t, err) + require.Equal(t, parentCID, got.blobCID) + require.Equal(t, int64(0), got.clipStartMS) + require.Nil(t, got.clipEndMS) + }) + + t.Run("clip resolves to parent blob + surfaces bounds", func(t *testing.T) { + got, err := s.resolveVideoBlob(ctx, clipURI) + require.NoError(t, err) + require.Equal(t, parentCID, got.blobCID, "clip should serve the parent's content") + require.Equal(t, int64(5_000), got.clipStartMS) + require.NotNil(t, got.clipEndMS) + require.Equal(t, int64(10_000), *got.clipEndMS) + }) + + t.Run("clip-of-clip is rejected", func(t *testing.T) { + // A second clip pointing at the first clip should fail — + // resolve only follows one hop and demands sourceTracks on + // the parent. + const nestedURI = "at://did:plc:owner/place.stream.video/nested" + nested := &streamplace.Video{ + LexiconTypeID: "place.stream.video", + Title: "nested clip", + Source: &streamplace.Video_Source{ + MediaDefs_SourceClip: &streamplace.MediaDefs_SourceClip{ + LexiconTypeID: "place.stream.media.defs#sourceClip", + Video: clipURI, + Start: 100, + End: 200, + }, + }, + } + require.NoError(t, m.UpsertVideo(ctx, &model.Video{ + URI: nestedURI, + CID: "bafynested", + RepoDID: owner, + RKey: "nested", + Title: nested.Title, + Record: videoRecordCBOR(t, nested), + IndexedAt: time.Now().UTC(), + })) + _, err := s.resolveVideoBlob(ctx, nestedURI) + require.Error(t, err) + he, ok := err.(*echo.HTTPError) + require.True(t, ok) + require.Equal(t, http.StatusUnprocessableEntity, he.Code) + }) +} + +func TestComposeClipBounds(t *testing.T) { + intPtr := func(v int64) *int64 { return &v } + deref := func(p *int64) (int64, bool) { + if p == nil { + return 0, false + } + return *p, true + } + + for _, tc := range []struct { + name string + clipStart int64 + clipEnd *int64 // nil = not a clip + queryStart *int64 + queryEnd *int64 + wantStart int64 + wantStartPresent bool + wantEnd int64 + wantEndPresent bool + }{ + { + name: "non-clip, no query: full video", + }, + { + name: "non-clip, query bounds: passthrough", + queryStart: intPtr(1_000), + queryEnd: intPtr(2_000), + wantStart: 1_000, + wantStartPresent: true, + wantEnd: 2_000, + wantEndPresent: true, + }, + { + name: "clip, no query: returns full clip in parent timeline", + clipStart: 5_000, + clipEnd: intPtr(10_000), + wantStart: 5_000, + wantStartPresent: true, + wantEnd: 10_000, + wantEndPresent: true, + }, + { + name: "clip + query within bounds: translated by clipStart", + clipStart: 5_000, + clipEnd: intPtr(10_000), + queryStart: intPtr(1_000), + queryEnd: intPtr(2_000), + wantStart: 6_000, + wantStartPresent: true, + wantEnd: 7_000, + wantEndPresent: true, + }, + { + name: "clip + query overshoots clip end: clamped", + clipStart: 5_000, + clipEnd: intPtr(10_000), + queryStart: intPtr(1_000), + queryEnd: intPtr(60_000), + wantStart: 6_000, + wantStartPresent: true, + wantEnd: 10_000, + wantEndPresent: true, + }, + { + name: "clip + query at 0: translates to clipStart", + clipStart: 5_000, + clipEnd: intPtr(10_000), + queryStart: intPtr(0), + wantStart: 5_000, + wantStartPresent: true, + wantEnd: 10_000, + wantEndPresent: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + gotStart, gotEnd := composeClipBounds(tc.clipStart, tc.clipEnd, tc.queryStart, tc.queryEnd) + s, sp := deref(gotStart) + e, ep := deref(gotEnd) + require.Equal(t, tc.wantStartPresent, sp, "start presence") + require.Equal(t, tc.wantEndPresent, ep, "end presence") + if sp { + require.Equal(t, tc.wantStart, s, "start value") + } + if ep { + require.Equal(t, tc.wantEnd, e, "end value") + } + }) + } +} + func TestParseSingleRange(t *testing.T) { cases := []struct { name string diff --git a/pkg/streamplace/playbackgetVideoPlaylist.go b/pkg/streamplace/playbackgetVideoPlaylist.go index 10d4d724..b237ab02 100644 --- a/pkg/streamplace/playbackgetVideoPlaylist.go +++ b/pkg/streamplace/playbackgetVideoPlaylist.go @@ -13,9 +13,9 @@ import ( // PlaybackGetVideoPlaylist calls the XRPC method "place.stream.playback.getVideoPlaylist". // -// end: End time in nanoseconds. Omit to include all remaining content. +// end: End time in milliseconds. Omit to include all remaining content. Local to the clip's timeline when playing back a sourceClip record. // sid: Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting. -// start: Start time in nanoseconds from the beginning of the video. Defaults to 0. +// start: Start time in milliseconds from the beginning of the video. Defaults to 0. For a place.stream.video record whose source is a place.stream.media.defs#sourceClip, this is in the clip's local timeline (0 == the clip's start). // track: Track ID (stringified u32 matching the MUXL container) for a single-track media playlist. Omit for the master playlist. // uri: AT-URI of the record to play back (e.g. at://did:plc:.../place.stream.video/). func PlaybackGetVideoPlaylist(ctx context.Context, c lexutil.LexClient, end int64, sid string, start int64, track string, uri string) ([]byte, error) {