diff --git a/js/docs/src/content/docs/lex-reference/media/place-stream-media-finalizelivestream.md b/js/docs/src/content/docs/lex-reference/media/place-stream-media-finalizelivestream.md
new file mode 100644
index 00000000..79e8f4f0
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/media/place-stream-media-finalizelivestream.md
@@ -0,0 +1,99 @@
+---
+title: place.stream.media.finalizeLivestream
+description: Reference for the place.stream.media.finalizeLivestream lexicon
+---
+
+**Lexicon Version:** 1
+
+## Definitions
+
+
+
+### `main`
+
+**Type:** `procedure`
+
+Turn a finished livestream into a VOD. The server concatenates the MUXL segments it recorded for the livestream into a single content blob, derives the playback sidecars, and publishes the place.stream.media.track records — the same end state as a finished upload. Returns an uploadId the client polls with place.stream.media.getUploadStatus and then publishes with place.stream.media.publishVideo, exactly as for a resumable upload.
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ------------ | -------- | ----- | ----------------------------------------------------------------------------------------------------------- | ---------------- |
+| `livestream` | `string` | ✅ | AT-URI of the place.stream.livestream record to finalize into a VOD. Must belong to the authenticated user. | Format: `at-uri` |
+
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | -------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| `uploadId` | `string` | ✅ | Identifier for the finalize job. Poll place.stream.media.getUploadStatus with it; once status is 'done', create the video with place.stream.media.publishVideo. | |
+
+**Possible Errors:**
+
+- `LivestreamNotFound`: No livestream with the given URI is known, or it does not belong to the authenticated user.
+- `NoRecording`: The livestream has no recorded MUXL segments to finalize (recording was not enabled, or none completed).
+
+---
+
+## Lexicon Source
+
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.media.finalizeLivestream",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Turn a finished livestream into a VOD. The server concatenates the MUXL segments it recorded for the livestream into a single content blob, derives the playback sidecars, and publishes the place.stream.media.track records — the same end state as a finished upload. Returns an uploadId the client polls with place.stream.media.getUploadStatus and then publishes with place.stream.media.publishVideo, exactly as for a resumable upload.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["livestream"],
+ "properties": {
+ "livestream": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.livestream record to finalize into a VOD. Must belong to the authenticated user."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uploadId"],
+ "properties": {
+ "uploadId": {
+ "type": "string",
+ "description": "Identifier for the finalize job. Poll place.stream.media.getUploadStatus with it; once status is 'done', create the video with place.stream.media.publishVideo."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "LivestreamNotFound",
+ "description": "No livestream with the given URI is known, or it does not belong to the authenticated user."
+ },
+ {
+ "name": "NoRecording",
+ "description": "The livestream has no recorded MUXL segments to finalize (recording was not enabled, or none completed)."
+ }
+ ]
+ }
+ }
+}
+```
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index dd385ba8..7f440acb 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -2286,6 +2286,77 @@
}
}
},
+ "/xrpc/place.stream.media.finalizeLivestream": {
+ "post": {
+ "summary": "Turn a finished livestream into a VOD. The server concatenates the MUXL segments it recorded for the livestream into a single content blob, derives the playback sidecars, and publishes the place.stream.media.track records — the same end state as a finished upload. Returns an uploadId the client polls with place.stream.media.getUploadStatus and then publishes with place.stream.media.publishVideo, exactly as for a resumable upload.",
+ "operationId": "place.stream.media.finalizeLivestream",
+ "tags": ["place.stream.media"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uploadId": {
+ "type": "string",
+ "description": "Identifier for the finalize job. Poll place.stream.media.getUploadStatus with it; once status is 'done', create the video with place.stream.media.publishVideo."
+ }
+ },
+ "required": ["uploadId"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": ["error", "message"],
+ "properties": {
+ "error": {
+ "type": "string",
+ "oneOf": [
+ {
+ "const": "LivestreamNotFound"
+ },
+ {
+ "const": "NoRecording"
+ }
+ ]
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "livestream": {
+ "type": "string",
+ "description": "AT-URI of the place.stream.livestream record to finalize into a VOD. Must belong to the authenticated user.",
+ "format": "uri"
+ }
+ },
+ "required": ["livestream"]
+ }
+ }
+ }
+ }
+ }
+ },
"/xrpc/place.stream.media.getUploadStatus": {
"get": {
"summary": "Get the processing status of a previously created upload. Only accessible by the DID that created the upload.",
diff --git a/lexicons/place/stream/media/finalizeLivestream.json b/lexicons/place/stream/media/finalizeLivestream.json
new file mode 100644
index 00000000..5c8c96af
--- /dev/null
+++ b/lexicons/place/stream/media/finalizeLivestream.json
@@ -0,0 +1,47 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.media.finalizeLivestream",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Turn a finished livestream into a VOD. The server concatenates the MUXL segments it recorded for the livestream into a single content blob, derives the playback sidecars, and publishes the place.stream.media.track records — the same end state as a finished upload. Returns an uploadId the client polls with place.stream.media.getUploadStatus and then publishes with place.stream.media.publishVideo, exactly as for a resumable upload.",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["livestream"],
+ "properties": {
+ "livestream": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "AT-URI of the place.stream.livestream record to finalize into a VOD. Must belong to the authenticated user."
+ }
+ }
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uploadId"],
+ "properties": {
+ "uploadId": {
+ "type": "string",
+ "description": "Identifier for the finalize job. Poll place.stream.media.getUploadStatus with it; once status is 'done', create the video with place.stream.media.publishVideo."
+ }
+ }
+ }
+ },
+ "errors": [
+ {
+ "name": "LivestreamNotFound",
+ "description": "No livestream with the given URI is known, or it does not belong to the authenticated user."
+ },
+ {
+ "name": "NoRecording",
+ "description": "The livestream has no recorded MUXL segments to finalize (recording was not enabled, or none completed)."
+ }
+ ]
+ }
+ }
+}
diff --git a/pkg/blob/s3.go b/pkg/blob/s3.go
index 22f0bd27..73dc3bf1 100644
--- a/pkg/blob/s3.go
+++ b/pkg/blob/s3.go
@@ -40,6 +40,12 @@ func (s *S3Store) URL(key string) string { return "s3://" + s.bucket + "/" + key
func (s *S3Store) Bucket() string { return s.bucket }
+// Client exposes the underlying S3 client so callers needing operations beyond
+// the blob.Store interface (e.g. the server-side multi-object concat in
+// pkg/s3.ConcatWithHeader for live-to-VOD finalize) can reuse the same
+// configured client + bucket.
+func (s *S3Store) Client() *awss3.Client { return s.client }
+
func (s *S3Store) Open(ctx context.Context, key string) (Reader, error) {
ra, err := s3pkg.NewReaderAt(ctx, s.client, s.bucket, key)
if err != nil {
diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go
index 31f9625f..cebfcb90 100644
--- a/pkg/cmd/streamplace.go
+++ b/pkg/cmd/streamplace.go
@@ -413,6 +413,21 @@ func runMain(ctx context.Context, build *config.BuildFlags, platformJobs []jobFu
Location: t.Location,
})
})
+ // Live-to-VOD finalize. Resolves the streamer's live signing key here
+ // (where the model is in scope) so pkg/vod stays free of pkg/model, then
+ // concatenates the recorded MUXL objects into a VOD.
+ state.SetLivestreamVODFinalizer(func(ctx context.Context, t statedb.FinalizeLivestreamVODTask) (string, error) {
+ signingKey, err := resolveLiveSigningKey(mod, t.RepoDID)
+ if err != nil {
+ return "", fmt.Errorf("finalize-livestream-vod: resolve signing key: %w", err)
+ }
+ return vod.FinalizeLivestreamVOD(ctx, cli, state, vodStore, vod.FinalizeInput{
+ UploadID: t.UploadID,
+ RepoDID: t.RepoDID,
+ LivestreamURI: t.LivestreamURI,
+ SigningKey: signingKey,
+ })
+ })
// View-count aggregator runs the log → record pipeline for one
// window. Same function-pointer pattern as the VOD processor so
// statedb stays free of viewlog's transitive deps. The scheduler
@@ -1139,3 +1154,29 @@ func makeMigrateCommand(build *config.BuildFlags) *urfavecli.Command {
},
}
}
+
+// resolveLiveSigningKey returns the did:key whose private half signed a
+// streamer's live segments, for stamping on live-to-VOD place.stream.media.track
+// records so playback can verify them. It picks the most recently created
+// non-revoked place.stream.key for the repo; streamers normally have exactly
+// one. Errors if the repo has no active signing key.
+func resolveLiveSigningKey(mod model.Model, repoDID string) (string, error) {
+ keys, err := mod.GetSigningKeysForRepo(repoDID)
+ if err != nil {
+ return "", err
+ }
+ var best *model.SigningKey
+ for i := range keys {
+ k := &keys[i]
+ if k.RevokedAt != nil {
+ continue
+ }
+ if best == nil || k.CreatedAt.After(best.CreatedAt) {
+ best = k
+ }
+ }
+ if best == nil {
+ return "", fmt.Errorf("no active signing key for repo %s", repoDID)
+ }
+ return best.DID, nil
+}
diff --git a/pkg/director/s3_upload.go b/pkg/director/s3_upload.go
index a2dad249..711d4ef7 100644
--- a/pkg/director/s3_upload.go
+++ b/pkg/director/s3_upload.go
@@ -2,13 +2,16 @@ package director
import (
"context"
- "time"
"stream.place/streamplace/pkg/log"
"stream.place/streamplace/pkg/media"
"stream.place/streamplace/pkg/s3"
)
+// liveRecPrefix namespaces in-progress livestream recordings in the S3 bucket
+// (keys are liveRecPrefix + / + .m4s).
+const liveRecPrefix = "live-rec/"
+
func (ss *StreamSession) maybeStartS3Upload(ctx context.Context, repoDID string) {
if !ss.cli.S3Configured() {
return
@@ -20,8 +23,18 @@ func (ss *StreamSession) maybeStartS3Upload(ctx context.Context, repoDID string)
SecretAccessKey: ss.cli.S3SecretAccessKey,
Region: ss.cli.S3Region,
}
- keyPrefix := repoDID + "/"
- ss.s3Uploader = s3.NewS3Uploader(cfg, repoDID, keyPrefix, time.Minute, ss.statefulDB)
+ // live-rec/ namespaces the in-progress livestream recordings away from the
+ // finalized VOD blobs (blobs/) and anything else in the bucket.
+ keyPrefix := liveRecPrefix + repoDID + "/"
+ ss.s3Uploader = s3.NewS3Uploader(cfg, repoDID, keyPrefix, s3.DefaultCutoverEvery, ss.statefulDB)
+ // Best-effort initial resolve of the livestream URI so the very first
+ // object is tagged. The director treats "latest livestream for repo" as
+ // the current stream everywhere (notification blast, idle finalize), so we
+ // do the same here; NewSegment refreshes it once the stream's own record is
+ // indexed, in case a prior stream was momentarily still "latest".
+ if ls, err := ss.mod.GetLatestLivestreamForRepo(repoDID); err == nil && ls != nil {
+ ss.s3Uploader.SetLivestreamURI(ls.URI)
+ }
log.Log(ctx, "S3 upload enabled", "bucket", ss.cli.S3Bucket, "endpoint", ss.cli.S3Endpoint)
}
diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go
index 3a8aba0e..5642b8af 100644
--- a/pkg/director/stream_session.go
+++ b/pkg/director/stream_session.go
@@ -126,7 +126,7 @@ func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotif
allRenditions = append([]renditions.Rendition{sourceRendition}, allRenditions...)
allRenditions = append(allRenditions, renditions.AudioRendition)
- // ss.maybeStartS3Upload(ctx, notif.Segment.RepoDID)
+ ss.maybeStartS3Upload(ctx, notif.Segment.RepoDID)
close(ss.started)
@@ -155,9 +155,13 @@ func (ss *StreamSession) Start(ctx context.Context, notif *media.NewSegmentNotif
case <-ss.segmentChan:
// reset timer
case <-ctx.Done():
- // Signal all background workers to stop
+ // Drain all in-flight session goroutines (including the per-segment
+ // AddSegment senders) BEFORE closing the uploader, so no segment
+ // send can race the uploader's channel close. s3Close then flushes
+ // the final object on the uploader's own (still-live) context.
+ err := ss.g.Wait()
ss.s3Close(ctx)
- return ss.g.Wait()
+ return err
// case <-time.After(time.Minute * 1):
case <-time.After(ss.cli.StreamSessionTimeout):
log.Log(ctx, "stream session timeout, shutting down", "timeout", ss.cli.StreamSessionTimeout)
@@ -238,7 +242,7 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment
return fmt.Errorf("could not convert segment to streamplace segment: %w", err)
}
- // ss.s3Upload(ctx, notif)
+ ss.s3Upload(ctx, notif)
ss.bus.Publish(spseg.Creator, spseg)
ss.Go(ctx, func() error {
@@ -299,6 +303,12 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment
log.Warn(ctx, "no livestream found, skipping notification blast", "repoDID", spseg.Creator)
return nil
}
+ // Refresh the S3 uploader's livestream tag now that this stream's
+ // own record is indexed (it may not have been when the uploader
+ // started), so all objects are attributed to the right stream.
+ if ss.s3Uploader != nil {
+ ss.s3Uploader.SetLivestreamURI(livestreamModel.URI)
+ }
lsv, err := livestreamModel.ToLivestreamView()
if err != nil {
return fmt.Errorf("failed to convert livestream to streamplace livestream: %w", err)
diff --git a/pkg/s3/concat.go b/pkg/s3/concat.go
new file mode 100644
index 00000000..d4f8b422
--- /dev/null
+++ b/pkg/s3/concat.go
@@ -0,0 +1,250 @@
+package s3
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/trace"
+ "stream.place/streamplace/pkg/log"
+)
+
+// ErrConcatPartTooSmall is returned by ConcatWithHeader when a non-final source
+// object is smaller than S3's 5 MB minimum part size and therefore can't be a
+// server-side UploadPartCopy part. The caller is expected to fall back to a
+// download-and-rewrite assembly. In production (10-minute cutover objects) this
+// never triggers; it only guards against pathologically short streams.
+var ErrConcatPartTooSmall = errors.New("s3 concat: non-final source object below 5MB minimum part size")
+
+// concatAPI is the subset of *s3.Client that ConcatWithHeader uses. Pulled out
+// so tests can inject a fake; *s3.Client satisfies it.
+type concatAPI interface {
+ copyAPI
+ GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error)
+ UploadPart(context.Context, *s3.UploadPartInput, ...func(*s3.Options)) (*s3.UploadPartOutput, error)
+}
+
+// ConcatWithHeader assembles destKey = header ++ srcKeys[0] ++ srcKeys[1] ++ …
+// (the source objects in order) as a single multipart object, transferring
+// almost everything server-side.
+//
+// The layout exists to give live-to-VOD finalize a content blob shaped exactly
+// like an uploaded VOD ([init][segments…]) without re-uploading the (possibly
+// many-GB) stream: the synthesized init header rides in part 1, and the bulk of
+// the bytes are copied with UploadPartCopy and never pass through this process.
+//
+// Part 1 (uploaded) is header plus just enough leading source bytes to clear
+// S3's 5 MB minimum, so it costs ~5 MB of memory + one upload. Every remaining
+// source object becomes one server-side UploadPartCopy part. S3 requires every
+// part except the last to be ≥5 MB; a non-final source object below that bound
+// can't be copied as its own part, so ConcatWithHeader returns
+// ErrConcatPartTooSmall and the caller falls back to a full rewrite.
+func ConcatWithHeader(ctx context.Context, client *s3.Client, bucket string, header []byte, srcKeys []string, dstKey, contentType string) error {
+ return concatWithHeader(ctx, client, bucket, header, srcKeys, dstKey, contentType)
+}
+
+func concatWithHeader(ctx context.Context, client concatAPI, bucket string, header []byte, srcKeys []string, dstKey, contentType string) error {
+ ctx = log.WithLogValues(ctx, "func", "s3.ConcatWithHeader")
+ ctx, span := s3Tracer.Start(ctx, "s3.ConcatWithHeader", trace.WithAttributes(
+ attribute.String("bucket", bucket),
+ attribute.String("dst_key", dstKey),
+ attribute.Int("src_count", len(srcKeys)),
+ attribute.Int("header_bytes", len(header)),
+ ))
+ defer span.End()
+
+ if len(srcKeys) == 0 {
+ return fmt.Errorf("s3 concat: no source objects")
+ }
+
+ // Object sizes drive both the part-1 fill and the per-object copy parts.
+ sizes := make([]int64, len(srcKeys))
+ for i, key := range srcKeys {
+ head, err := client.HeadObject(ctx, &s3.HeadObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(key),
+ })
+ if err != nil {
+ span.RecordError(err)
+ return fmt.Errorf("head s3://%s/%s: %w", bucket, key, err)
+ }
+ sizes[i] = aws.ToInt64(head.ContentLength)
+ }
+
+ create := &s3.CreateMultipartUploadInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(dstKey),
+ }
+ if contentType != "" {
+ create.ContentType = aws.String(contentType)
+ }
+ resp, err := client.CreateMultipartUpload(ctx, create)
+ if err != nil {
+ span.RecordError(err)
+ return fmt.Errorf("create multipart upload s3://%s/%s: %w", bucket, dstKey, err)
+ }
+ uploadID := aws.ToString(resp.UploadId)
+
+ abort := func() {
+ // Use ctx (not a cancelled child) so cleanup runs; best-effort.
+ _, _ = client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(dstKey),
+ UploadId: aws.String(uploadID),
+ })
+ }
+
+ var parts []types.CompletedPart
+ var partNum int32
+
+ // --- Part 1: header + leading source bytes until >= minPartSize. ---
+ // We pull only enough source bytes to clear the 5 MB floor (bounding memory
+ // to ~minPartSize), tracking which object we stopped in so the copy phase
+ // resumes from exactly there.
+ buf := bytes.NewBuffer(make([]byte, 0, 2*minPartSize+len(header)))
+ buf.Write(header)
+ idx := 0 // index of the source object the copy phase resumes at
+ var consumedInIdx int64 // bytes of srcKeys[idx] already pulled into part 1
+ for idx < len(srcKeys) && int64(buf.Len()) < minPartSize {
+ remaining := int64(minPartSize) - int64(buf.Len())
+ avail := sizes[idx] - consumedInIdx
+ take := remaining
+ if take > avail {
+ take = avail
+ }
+ // Look-ahead: a partial take that leaves this (non-final) object with a
+ // sub-5MB remainder would make that remainder an illegal small middle
+ // copy part. Absorb the whole object into part 1 instead — costing a
+ // little extra upload but keeping every copy part legal.
+ if take < avail && (avail-take) < int64(minPartSize) && idx != len(srcKeys)-1 {
+ take = avail
+ }
+ if take > 0 {
+ body, err := getRange(ctx, client, bucket, srcKeys[idx], consumedInIdx, consumedInIdx+take-1)
+ if err != nil {
+ abort()
+ span.RecordError(err)
+ return err
+ }
+ buf.Write(body)
+ consumedInIdx += take
+ }
+ if consumedInIdx >= sizes[idx] {
+ idx++
+ consumedInIdx = 0
+ }
+ }
+
+ partNum++
+ if err := uploadPart(ctx, client, bucket, dstKey, uploadID, partNum, buf.Bytes(), &parts); err != nil {
+ abort()
+ span.RecordError(err)
+ span.SetStatus(codes.Error, "upload_part_1")
+ return err
+ }
+
+ // --- Remaining objects: one server-side UploadPartCopy part each. ---
+ for i := idx; i < len(srcKeys); i++ {
+ start := int64(0)
+ if i == idx {
+ start = consumedInIdx // resume mid-object if part 1 stopped here
+ }
+ if start >= sizes[i] {
+ continue // wholly absorbed into part 1
+ }
+ partSize := sizes[i] - start
+ isLast := i == len(srcKeys)-1
+ if !isLast && partSize < minPartSize {
+ abort()
+ err := fmt.Errorf("%w (object %s contributes %d bytes)", ErrConcatPartTooSmall, srcKeys[i], partSize)
+ span.RecordError(err)
+ return err
+ }
+ if partSize > maxCopyObjectSize {
+ abort()
+ err := fmt.Errorf("s3 concat: object %s range %d bytes exceeds single-part copy limit", srcKeys[i], partSize)
+ span.RecordError(err)
+ return err
+ }
+ partNum++
+ res, err := client.UploadPartCopy(ctx, &s3.UploadPartCopyInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(dstKey),
+ UploadId: aws.String(uploadID),
+ PartNumber: aws.Int32(partNum),
+ CopySource: aws.String(bucket + "/" + srcKeys[i]),
+ CopySourceRange: aws.String(fmt.Sprintf("bytes=%d-%d", start, sizes[i]-1)),
+ })
+ if err != nil {
+ abort()
+ span.RecordError(err)
+ span.SetStatus(codes.Error, "upload_part_copy")
+ return fmt.Errorf("upload part copy %d s3://%s/%s: %w", partNum, bucket, srcKeys[i], err)
+ }
+ if res.CopyPartResult == nil {
+ abort()
+ return fmt.Errorf("upload part copy %d s3://%s/%s: missing CopyPartResult", partNum, bucket, srcKeys[i])
+ }
+ parts = append(parts, types.CompletedPart{
+ ETag: res.CopyPartResult.ETag,
+ PartNumber: aws.Int32(partNum),
+ })
+ }
+
+ if _, err := client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(dstKey),
+ UploadId: aws.String(uploadID),
+ MultipartUpload: &types.CompletedMultipartUpload{Parts: parts},
+ }); err != nil {
+ abort()
+ span.RecordError(err)
+ span.SetStatus(codes.Error, "complete")
+ return fmt.Errorf("complete multipart upload s3://%s/%s: %w", bucket, dstKey, err)
+ }
+ log.Log(ctx, "completed S3 header concat", "bucket", bucket, "key", dstKey, "parts", len(parts))
+ return nil
+}
+
+// getRange reads [start,end] (inclusive) of an object into memory.
+func getRange(ctx context.Context, client concatAPI, bucket, key string, start, end int64) ([]byte, error) {
+ out, err := client.GetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(key),
+ Range: aws.String(fmt.Sprintf("bytes=%d-%d", start, end)),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("get s3://%s/%s bytes=%d-%d: %w", bucket, key, start, end, err)
+ }
+ defer out.Body.Close()
+ body, err := io.ReadAll(out.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read s3://%s/%s body: %w", bucket, key, err)
+ }
+ return body, nil
+}
+
+func uploadPart(ctx context.Context, client concatAPI, bucket, dstKey, uploadID string, partNum int32, body []byte, parts *[]types.CompletedPart) error {
+ res, err := client.UploadPart(ctx, &s3.UploadPartInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(dstKey),
+ UploadId: aws.String(uploadID),
+ PartNumber: aws.Int32(partNum),
+ Body: bytes.NewReader(body),
+ })
+ if err != nil {
+ return fmt.Errorf("upload part %d s3://%s/%s: %w", partNum, bucket, dstKey, err)
+ }
+ *parts = append(*parts, types.CompletedPart{
+ ETag: res.ETag,
+ PartNumber: aws.Int32(partNum),
+ })
+ return nil
+}
diff --git a/pkg/s3/concat_test.go b/pkg/s3/concat_test.go
new file mode 100644
index 00000000..d02af9e2
--- /dev/null
+++ b/pkg/s3/concat_test.go
@@ -0,0 +1,214 @@
+package s3
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+)
+
+// fakeConcatS3 is an in-memory stand-in for the subset of S3 ConcatWithHeader
+// uses. CompleteMultipartUpload reconstructs the destination object from the
+// recorded parts in part-number order and enforces S3's rule that every part
+// except the last is ≥5MB, so a ConcatWithHeader bug that emits a small middle
+// part fails loudly here rather than only against real S3.
+type fakeConcatS3 struct {
+ objects map[string][]byte
+ parts map[int32][]byte // partNumber -> bytes, for the single in-flight upload
+ assembled map[string][]byte
+ aborted bool
+}
+
+func newFakeConcatS3(objects map[string][]byte) *fakeConcatS3 {
+ return &fakeConcatS3{
+ objects: objects,
+ parts: map[int32][]byte{},
+ assembled: map[string][]byte{},
+ }
+}
+
+func (f *fakeConcatS3) HeadObject(_ context.Context, in *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
+ b, ok := f.objects[aws.ToString(in.Key)]
+ if !ok {
+ return nil, fmt.Errorf("no such object %s", aws.ToString(in.Key))
+ }
+ return &awss3.HeadObjectOutput{ContentLength: aws.Int64(int64(len(b)))}, nil
+}
+
+func (f *fakeConcatS3) GetObject(_ context.Context, in *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
+ b, ok := f.objects[aws.ToString(in.Key)]
+ if !ok {
+ return nil, fmt.Errorf("no such object %s", aws.ToString(in.Key))
+ }
+ start, end := parseRange(aws.ToString(in.Range), len(b))
+ chunk := b[start : end+1]
+ return &awss3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(chunk))}, nil
+}
+
+func (f *fakeConcatS3) CreateMultipartUpload(_ context.Context, _ *awss3.CreateMultipartUploadInput, _ ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error) {
+ return &awss3.CreateMultipartUploadOutput{UploadId: aws.String("upload-1")}, nil
+}
+
+func (f *fakeConcatS3) UploadPart(_ context.Context, in *awss3.UploadPartInput, _ ...func(*awss3.Options)) (*awss3.UploadPartOutput, error) {
+ body, _ := io.ReadAll(in.Body)
+ f.parts[aws.ToInt32(in.PartNumber)] = body
+ return &awss3.UploadPartOutput{ETag: aws.String(fmt.Sprintf("etag-%d", aws.ToInt32(in.PartNumber)))}, nil
+}
+
+func (f *fakeConcatS3) UploadPartCopy(_ context.Context, in *awss3.UploadPartCopyInput, _ ...func(*awss3.Options)) (*awss3.UploadPartCopyOutput, error) {
+ // CopySource is "bucket/key"; strip the bucket prefix.
+ src := aws.ToString(in.CopySource)
+ key := src[strings.Index(src, "/")+1:]
+ b, ok := f.objects[key]
+ if !ok {
+ return nil, fmt.Errorf("copy from missing object %s", key)
+ }
+ start, end := parseRange(aws.ToString(in.CopySourceRange), len(b))
+ f.parts[aws.ToInt32(in.PartNumber)] = append([]byte(nil), b[start:end+1]...)
+ return &awss3.UploadPartCopyOutput{
+ CopyPartResult: &types.CopyPartResult{ETag: aws.String(fmt.Sprintf("etag-%d", aws.ToInt32(in.PartNumber)))},
+ }, nil
+}
+
+func (f *fakeConcatS3) CompleteMultipartUpload(_ context.Context, in *awss3.CompleteMultipartUploadInput, _ ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error) {
+ nums := make([]int32, 0, len(in.MultipartUpload.Parts))
+ for _, p := range in.MultipartUpload.Parts {
+ nums = append(nums, aws.ToInt32(p.PartNumber))
+ }
+ sort.Slice(nums, func(i, j int) bool { return nums[i] < nums[j] })
+ var out []byte
+ for i, n := range nums {
+ body := f.parts[n]
+ isLast := i == len(nums)-1
+ if !isLast && len(body) < minPartSize {
+ return nil, fmt.Errorf("EntityTooSmall: part %d is %d bytes (<5MB) and not last", n, len(body))
+ }
+ out = append(out, body...)
+ }
+ f.assembled[aws.ToString(in.Key)] = out
+ return &awss3.CompleteMultipartUploadOutput{}, nil
+}
+
+func (f *fakeConcatS3) AbortMultipartUpload(_ context.Context, _ *awss3.AbortMultipartUploadInput, _ ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) {
+ f.aborted = true
+ return &awss3.AbortMultipartUploadOutput{}, nil
+}
+
+// CopyObject is required to satisfy copyAPI (embedded in concatAPI) but unused.
+func (f *fakeConcatS3) CopyObject(_ context.Context, _ *awss3.CopyObjectInput, _ ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error) {
+ return nil, errors.New("unexpected CopyObject")
+}
+
+func parseRange(r string, total int) (int64, int64) {
+ // "bytes=start-end"
+ spec := strings.TrimPrefix(r, "bytes=")
+ parts := strings.SplitN(spec, "-", 2)
+ start, _ := strconv.ParseInt(parts[0], 10, 64)
+ end, _ := strconv.ParseInt(parts[1], 10, 64)
+ if end > int64(total)-1 {
+ end = int64(total) - 1
+ }
+ return start, end
+}
+
+func filled(b byte, n int) []byte {
+ out := make([]byte, n)
+ for i := range out {
+ out[i] = b
+ }
+ return out
+}
+
+const mb = 1024 * 1024
+
+func TestConcatWithHeader(t *testing.T) {
+ header := filled('H', 1024)
+
+ cases := []struct {
+ name string
+ objects map[string][]byte
+ order []string
+ wantErr error
+ }{
+ {
+ name: "large objects partial first part",
+ objects: map[string][]byte{
+ "a": filled('a', 10*mb),
+ "b": filled('b', 8*mb),
+ "c": filled('c', 7*mb),
+ },
+ order: []string{"a", "b", "c"},
+ },
+ {
+ name: "medium objects absorb whole first object",
+ objects: map[string][]byte{
+ "a": filled('a', 6*mb),
+ "b": filled('b', 6*mb),
+ "c": filled('c', 6*mb),
+ },
+ order: []string{"a", "b", "c"},
+ },
+ {
+ name: "single small object is the only part",
+ objects: map[string][]byte{
+ "a": filled('a', 2*mb),
+ },
+ order: []string{"a"},
+ },
+ {
+ name: "small last object is allowed",
+ objects: map[string][]byte{
+ "a": filled('a', 8*mb),
+ "b": filled('b', 1*mb),
+ },
+ order: []string{"a", "b"},
+ },
+ {
+ name: "small middle object falls back",
+ objects: map[string][]byte{
+ "a": filled('a', 6*mb),
+ "b": filled('b', 2*mb),
+ "c": filled('c', 6*mb),
+ },
+ order: []string{"a", "b", "c"},
+ wantErr: ErrConcatPartTooSmall,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ fake := newFakeConcatS3(tc.objects)
+ err := concatWithHeader(context.Background(), fake, "bucket", header, tc.order, "dst", "video/mp4")
+ if tc.wantErr != nil {
+ if !errors.Is(err, tc.wantErr) {
+ t.Fatalf("want error %v, got %v", tc.wantErr, err)
+ }
+ if !fake.aborted {
+ t.Fatalf("expected multipart upload to be aborted on too-small part")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("concatWithHeader: %v", err)
+ }
+ // Expected = header ++ objects in order.
+ want := append([]byte(nil), header...)
+ for _, k := range tc.order {
+ want = append(want, tc.objects[k]...)
+ }
+ got := fake.assembled["dst"]
+ if !bytes.Equal(got, want) {
+ t.Fatalf("assembled mismatch: got %d bytes, want %d bytes", len(got), len(want))
+ }
+ })
+ }
+}
diff --git a/pkg/s3/s3.go b/pkg/s3/s3.go
index 22473c71..7d4c0f17 100644
--- a/pkg/s3/s3.go
+++ b/pkg/s3/s3.go
@@ -4,6 +4,8 @@ import (
"bytes"
"context"
"fmt"
+ "sync"
+ "sync/atomic"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
@@ -11,7 +13,6 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"stream.place/streamplace/pkg/log"
- "stream.place/streamplace/pkg/muxl"
)
// Config holds the configuration for an S3-compatible upload target.
@@ -26,18 +27,21 @@ type Config struct {
// Recorder is an optional persistence hook for S3Uploader. RecordStart is
// called when a new multipart upload begins; the returned id is passed back
// to RecordComplete when the upload is finalized. Implementations should
-// tolerate nil contexts being passed.
+// tolerate nil contexts being passed. livestreamURI ties the object to the
+// livestream it belongs to (may be empty if not yet known) so the live-to-VOD
+// finalize can later enumerate exactly the objects for one stream.
type Recorder interface {
- RecordStart(ctx context.Context, userDID, bucket, key string, started time.Time) (id string, err error)
+ RecordStart(ctx context.Context, userDID, bucket, key, livestreamURI string, started time.Time) (id string, err error)
RecordComplete(ctx context.Context, id string, parts int32, size int64) error
}
// S3Uploader manages streaming multipart uploads to an S3-compatible endpoint.
-// Bare canonical MUXL segments are fed via AddSegment; they concatenate
-// directly (the format is naively concatenable). A single init segment,
-// synthesized from the first segment, is prepended to each multipart object so
-// each object is a valid standalone MP4. Every cutoverEvery, the current upload
-// is completed and a new one begins.
+// Bare canonical MUXL segments are fed via AddSegment and written verbatim —
+// no per-object init header — so the objects of one stream concatenate
+// directly into a single MUXL byte stream. The live-to-VOD finalize prepends
+// one synthesized init to the whole concatenation; keeping the objects bare is
+// what lets it "concat fearlessly". Every cutoverEvery, the current upload is
+// completed and a new one begins.
type S3Uploader struct {
client *s3.Client
bucket string
@@ -47,6 +51,28 @@ type S3Uploader struct {
segCh chan []byte // bare canonical MUXL segments awaiting upload
done chan error
recorder Recorder
+
+ mu sync.Mutex
+ livestreamURI string // guarded by mu; stamped on each S3Segment row
+
+ closeOnce sync.Once
+ closeErr error
+ closed atomic.Bool
+}
+
+// SetLivestreamURI records the livestream this stream's objects belong to. It
+// may be called once the URI is resolved (it can be unknown when the uploader
+// starts); subsequent multipart objects are stamped with it via RecordStart.
+func (u *S3Uploader) SetLivestreamURI(uri string) {
+ u.mu.Lock()
+ u.livestreamURI = uri
+ u.mu.Unlock()
+}
+
+func (u *S3Uploader) getLivestreamURI() string {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ return u.livestreamURI
}
// S3 requires each part except the last to be at least 5MB.
@@ -100,8 +126,15 @@ func NewS3Uploader(cfg Config, userDID, keyPrefix string, cutoverEvery time.Dura
}
// AddSegment feeds one bare canonical MUXL segment (uuid+moof+mdat per track)
-// for upload. The bytes are copied, so the caller may reuse its buffer.
+// for upload. The bytes are copied, so the caller may reuse its buffer. It is
+// the caller's responsibility not to call AddSegment concurrently with Close
+// (the StreamSession guarantees this by draining its goroutines before closing,
+// see director.Start); the closed guard here is a best-effort backstop that
+// turns a late call into an error rather than a send-on-closed-channel panic.
func (u *S3Uploader) AddSegment(ctx context.Context, data []byte) error {
+ if u.closed.Load() {
+ return fmt.Errorf("s3 uploader closed")
+ }
seg := append([]byte(nil), data...)
select {
case u.segCh <- seg:
@@ -111,32 +144,39 @@ func (u *S3Uploader) AddSegment(ctx context.Context, data []byte) error {
}
}
-// Close signals that no more segments will be added, waits for all
-// in-flight uploads to complete, and returns any error.
+// Close signals that no more segments will be added, waits for all in-flight
+// uploads to complete, and returns any error. It is idempotent: repeated calls
+// return the same result without re-closing the channel. The supplied ctx is
+// unused for the wait (uploadLoop runs on its own context so it can flush the
+// final object even after the session context is cancelled) but kept for API
+// symmetry.
func (u *S3Uploader) Close(ctx context.Context) error {
- close(u.segCh)
- if uploadErr := <-u.done; uploadErr != nil {
- return fmt.Errorf("error uploading: %w", uploadErr)
- }
- return nil
+ u.closeOnce.Do(func() {
+ u.closed.Store(true)
+ close(u.segCh)
+ if uploadErr := <-u.done; uploadErr != nil {
+ u.closeErr = fmt.Errorf("error uploading: %w", uploadErr)
+ }
+ })
+ return u.closeErr
}
// uploadLoop reads bare segments off segCh and manages multipart uploads.
-// Runs until segCh is closed (Close) or ctx is canceled.
+// Runs until segCh is closed (Close) or ctx is canceled. ctx is intentionally
+// independent of the session context so a final object can still be completed
+// after the stream tears down.
func (u *S3Uploader) uploadLoop(ctx context.Context) {
ctx = log.WithLogValues(ctx, "func", "s3.uploadLoop")
- var initSeg []byte
var current *activeUpload
- // Helper: prepend init to buffer when starting a new upload
startUpload := func() error {
now := time.Now()
- key := fmt.Sprintf("%s%s.mp4", u.keyPrefix, now.UTC().Format("2006-01-02T15-04-05"))
+ key := fmt.Sprintf("%s%s.m4s", u.keyPrefix, now.UTC().Format("2006-01-02T15-04-05"))
resp, err := u.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(u.bucket),
Key: aws.String(key),
- ContentType: aws.String("video/mp4"),
+ ContentType: aws.String("video/iso.segment"),
})
if err != nil {
return fmt.Errorf("creating multipart upload for %s: %w", key, err)
@@ -148,16 +188,12 @@ func (u *S3Uploader) uploadLoop(ctx context.Context) {
started: now,
}
if u.recorder != nil {
- id, recErr := u.recorder.RecordStart(ctx, u.userDID, u.bucket, key, now)
+ id, recErr := u.recorder.RecordStart(ctx, u.userDID, u.bucket, key, u.getLivestreamURI(), now)
if recErr != nil {
log.Error(ctx, "recording S3 upload start", "key", key, "error", recErr)
}
current.recordID = id
}
- // Prepend init segment to the buffer so the file starts valid
- if initSeg != nil {
- current.buf = append(current.buf, initSeg...)
- }
log.Log(ctx, "started S3 multipart upload", "key", key)
return nil
}
@@ -273,18 +309,6 @@ func (u *S3Uploader) uploadLoop(ctx context.Context) {
return
}
log.Debug(ctx, "received segment for S3 upload", "size", len(seg))
- // Synthesize the init segment once, from the first segment's
- // embedded catalog; it's prepended to each multipart object.
- if initSeg == nil {
- var initBuf bytes.Buffer
- if werr := muxl.RunMuxlWrapInit(ctx, bytes.NewReader(seg), &initBuf); werr != nil {
- err = fmt.Errorf("synthesizing init segment: %w", werr)
- u.done <- err
- return
- }
- initSeg = initBuf.Bytes()
- log.Debug(ctx, "synthesized init segment for S3 upload", "size", len(initSeg))
- }
if err = handleSegment(seg); err != nil {
log.Error(ctx, "error handling segment", "error", err)
}
diff --git a/pkg/s3/uploader_test.go b/pkg/s3/uploader_test.go
new file mode 100644
index 00000000..8324cd63
--- /dev/null
+++ b/pkg/s3/uploader_test.go
@@ -0,0 +1,46 @@
+package s3
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+)
+
+// TestS3UploaderCloseIdempotent exercises the lifecycle fix that re-enabled
+// live S3 upload: Close must be safe to call repeatedly and concurrently (it
+// was a plain close(segCh) before, which panicked on the second call), and
+// AddSegment after Close must return an error rather than panic with
+// "send on closed channel". No segments are added, so the upload loop completes
+// without making any S3 calls — this stays a pure unit test.
+func TestS3UploaderCloseIdempotent(t *testing.T) {
+ u := NewS3Uploader(Config{
+ Region: "us-east-1",
+ Endpoint: "http://127.0.0.1:0",
+ Bucket: "test",
+ AccessKeyID: "k",
+ SecretAccessKey: "s",
+ }, "did:plc:test", "did:plc:test/", time.Minute, nil)
+
+ const n = 4
+ var wg sync.WaitGroup
+ errs := make([]error, n)
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ errs[i] = u.Close(context.Background())
+ }(i)
+ }
+ wg.Wait()
+ for i, err := range errs {
+ if err != nil {
+ t.Fatalf("Close call %d returned error: %v", i, err)
+ }
+ }
+
+ // A late AddSegment must be rejected, not panic on a closed channel.
+ if err := u.AddSegment(context.Background(), []byte("late")); err == nil {
+ t.Fatalf("AddSegment after Close should return an error")
+ }
+}
diff --git a/pkg/spxrpc/place_stream_media_finalizelivestream.go b/pkg/spxrpc/place_stream_media_finalizelivestream.go
new file mode 100644
index 00000000..1e2c9feb
--- /dev/null
+++ b/pkg/spxrpc/place_stream_media_finalizelivestream.go
@@ -0,0 +1,71 @@
+package spxrpc
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/labstack/echo/v4"
+ "github.com/streamplace/oatproxy/pkg/oatproxy"
+ "stream.place/streamplace/pkg/statedb"
+ placestream "stream.place/streamplace/pkg/streamplace"
+)
+
+// handlePlaceStreamMediaFinalizeLivestream turns a finished livestream into a
+// VOD. It creates a synthetic Upload row and enqueues a background finalize
+// task that concatenates the livestream's recorded MUXL objects into a content
+// blob and publishes the track records — landing in the same place a finished
+// resumable upload does, so the client polls getUploadStatus and then
+// publishVideo with the returned uploadId, unchanged.
+func (s *Server) handlePlaceStreamMediaFinalizeLivestream(ctx context.Context, body *placestream.MediaFinalizeLivestream_Input) (*placestream.MediaFinalizeLivestream_Output, error) {
+ session, _ := oatproxy.GetOAuthSession(ctx)
+ if session == nil {
+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session required")
+ }
+ if body.Livestream == "" {
+ return nil, echo.NewHTTPError(http.StatusBadRequest, "livestream is required")
+ }
+ // A banned account can't mint new VODs (the playback gates would hide them
+ // regardless, but skip the work and the dead records).
+ if banned, err := s.accountBanned(session.DID); err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ } else if banned {
+ return nil, echo.NewHTTPError(http.StatusForbidden, "account is not permitted to publish videos")
+ }
+
+ ls, err := s.model.GetLivestream(body.Livestream)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ if ls == nil || ls.RepoDID != session.DID {
+ // Don't distinguish "not found" from "not yours" — same response.
+ return nil, echo.NewHTTPError(http.StatusNotFound, "livestream not found")
+ }
+
+ // Synthetic Upload row so the client reuses the getUploadStatus /
+ // publishVideo flow it already has for resumable uploads.
+ uu, err := uuid.NewV7()
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ uploadID := uu.String()
+ if err := s.statefulDB.CreateUpload(ctx, &statedb.Upload{
+ ID: uploadID,
+ RepoDID: session.DID,
+ MimeType: "video/mp4",
+ Backend: "live",
+ Location: body.Livestream,
+ }); err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ if _, err := s.statefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestreamVOD, statedb.FinalizeLivestreamVODTask{
+ UploadID: uploadID,
+ RepoDID: session.DID,
+ LivestreamURI: body.Livestream,
+ }, statedb.WithTaskKey("finalize-vod:"+uploadID)); err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
+ return &placestream.MediaFinalizeLivestream_Output{UploadId: uploadID}, nil
+}
diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go
index abbddb30..77c15f0f 100644
--- a/pkg/spxrpc/stubs.go
+++ b/pkg/spxrpc/stubs.go
@@ -305,6 +305,7 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.POST("/xrpc/place.stream.live.startLivestream", s.HandlePlaceStreamLiveStartLivestream)
e.POST("/xrpc/place.stream.live.stopLivestream", s.HandlePlaceStreamLiveStopLivestream)
e.POST("/xrpc/place.stream.media.createUpload", s.HandlePlaceStreamMediaCreateUpload)
+ e.POST("/xrpc/place.stream.media.finalizeLivestream", s.HandlePlaceStreamMediaFinalizeLivestream)
e.GET("/xrpc/place.stream.media.getUploadStatus", s.HandlePlaceStreamMediaGetUploadStatus)
e.GET("/xrpc/place.stream.media.getVideo", s.HandlePlaceStreamMediaGetVideo)
e.GET("/xrpc/place.stream.media.getVideoList", s.HandlePlaceStreamMediaGetVideoList)
@@ -745,6 +746,24 @@ func (s *Server) HandlePlaceStreamMediaCreateUpload(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamMediaFinalizeLivestream(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamMediaFinalizeLivestream")
+ defer span.End()
+
+ var body placestream.MediaFinalizeLivestream_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.MediaFinalizeLivestream_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamMediaFinalizeLivestream(ctx context.Context,body *placestream.MediaFinalizeLivestream_Input) (*placestream.MediaFinalizeLivestream_Output, error)
+ out, handleErr = s.handlePlaceStreamMediaFinalizeLivestream(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamMediaGetUploadStatus(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamMediaGetUploadStatus")
defer span.End()
diff --git a/pkg/statedb/queue_processor.go b/pkg/statedb/queue_processor.go
index 29a4b40f..a685beae 100644
--- a/pkg/statedb/queue_processor.go
+++ b/pkg/statedb/queue_processor.go
@@ -24,6 +24,7 @@ import (
var TaskNotification = "notification"
var TaskChat = "chat"
var TaskFinalizeLivestream = "finalize_livestream"
+var TaskFinalizeLivestreamVOD = "finalize_livestream_vod"
var TaskVODProcess = "vod_process"
var TaskViewCountAggregate = "view_count_aggregate"
@@ -69,6 +70,16 @@ type VODProcessTask struct {
Location string `json:"location"`
}
+// FinalizeLivestreamVODTask is enqueued by the place.stream.media.finalizeLivestream
+// procedure to turn a finished livestream's recorded MUXL objects into a VOD.
+// UploadID is the synthetic Upload row the client polls (getUploadStatus) and
+// then publishes (publishVideo), exactly as for a resumable upload.
+type FinalizeLivestreamVODTask struct {
+ UploadID string `json:"uploadId"`
+ RepoDID string `json:"repoDID"`
+ LivestreamURI string `json:"livestreamURI"`
+}
+
// ViewCountAggregateTask is the payload for one aggregation window.
// Enqueued by every streamplace node at the configured interval; the
// unique task key (built from WindowStart/End) ensures only one node's
@@ -97,11 +108,13 @@ func (state *StatefulDB) ProcessQueue(ctx context.Context, vodConcurrency int) e
return state.runQueueWorker(ctx, "queue_processor", nonVODTaskTypes)
})
- // Dedicated VOD pool.
+ // Dedicated VOD pool. Live-to-VOD finalize also runs here: it's heavy
+ // I/O (a full read of the recorded stream to hash + index it) that would
+ // otherwise hog the single general worker and stall light tasks.
for i := 0; i < vodConcurrency; i++ {
workerID := fmt.Sprintf("vod_worker_%d", i)
group.Go(func() error {
- return state.runQueueWorker(ctx, workerID, []string{TaskVODProcess})
+ return state.runQueueWorker(ctx, workerID, []string{TaskVODProcess, TaskFinalizeLivestreamVOD})
})
}
@@ -144,6 +157,8 @@ func (state *StatefulDB) processTask(ctx context.Context, task *AppTask) error {
return state.processFinalizeLivestreamTask(ctx, task)
case TaskVODProcess:
return state.processVODProcessTask(ctx, task)
+ case TaskFinalizeLivestreamVOD:
+ return state.processFinalizeLivestreamVODTask(ctx, task)
case TaskViewCountAggregate:
return state.processViewCountAggregateTask(ctx, task)
default:
@@ -192,6 +207,45 @@ func (state *StatefulDB) processVODProcessTask(ctx context.Context, task *AppTas
return state.CompleteTask(ctx, task.ID)
}
+// LivestreamVODFinalizer concatenates a finished livestream's recorded MUXL
+// objects into a content-addressed VOD blob, derives its sidecars, and
+// publishes the origin + track records. Returns the resulting BDASL CID. Same
+// function-pointer indirection as VODProcessor so pkg/statedb doesn't import
+// the blob.Store/muxl-heavy pkg/vod.
+type LivestreamVODFinalizer func(ctx context.Context, t FinalizeLivestreamVODTask) (cid string, err error)
+
+func (state *StatefulDB) SetLivestreamVODFinalizer(f LivestreamVODFinalizer) {
+ state.livestreamVODFinalizer = f
+}
+
+func (state *StatefulDB) processFinalizeLivestreamVODTask(ctx context.Context, task *AppTask) error {
+ ctx = log.WithLogValues(ctx, "func", "processFinalizeLivestreamVODTask")
+ var t FinalizeLivestreamVODTask
+ if err := json.Unmarshal(task.Payload, &t); err != nil {
+ return err
+ }
+ if state.livestreamVODFinalizer == nil {
+ log.Warn(ctx, "no livestream VOD finalizer configured; dropping task",
+ "uploadId", t.UploadID, "did", t.RepoDID)
+ return state.CompleteTask(ctx, task.ID)
+ }
+ if err := state.SetUploadProcessing(ctx, t.UploadID); err != nil {
+ log.Warn(ctx, "failed to mark upload as processing", "uploadId", t.UploadID, "error", err)
+ }
+ cid, err := state.livestreamVODFinalizer(ctx, t)
+ if err != nil {
+ if ferr := state.SetUploadFailed(ctx, t.UploadID, err.Error()); ferr != nil {
+ log.Warn(ctx, "failed to mark upload as failed", "uploadId", t.UploadID, "error", ferr)
+ }
+ // Complete so it doesn't retry: most finalize failures are permanent
+ // (missing objects, unreadable bytes, no OAuth session).
+ _ = state.CompleteTask(ctx, task.ID)
+ return fmt.Errorf("finalize livestream VOD upload %s: %w", t.UploadID, err)
+ }
+ log.Log(ctx, "livestream VOD finalized", "uploadId", t.UploadID, "cid", cid)
+ return state.CompleteTask(ctx, task.ID)
+}
+
// ViewCountAggregator runs the view-log → place.stream.media.viewCount
// aggregation for one window. Same function-pointer indirection trick
// as VODProcessor: pkg/statedb stays ignorant of pkg/viewlog (which
diff --git a/pkg/statedb/s3_segment.go b/pkg/statedb/s3_segment.go
index 8b18a166..3a11e6b0 100644
--- a/pkg/statedb/s3_segment.go
+++ b/pkg/statedb/s3_segment.go
@@ -9,17 +9,21 @@ import (
)
type S3Segment struct {
- ID string `gorm:"column:id;primarykey"`
- RepoDID string `gorm:"column:user_did;index;not null"`
- Bucket string `gorm:"column:bucket;not null"`
- Key string `gorm:"column:key;not null"`
- URL string `gorm:"column:url"`
- StartedAt time.Time `gorm:"column:started_at"`
- CompletedAt *time.Time `gorm:"column:completed_at"`
- Size int64 `gorm:"column:size"`
- PartCount int32 `gorm:"column:part_count"`
- CreatedAt time.Time `gorm:"column:created_at"`
- UpdatedAt time.Time `gorm:"column:updated_at"`
+ ID string `gorm:"column:id;primarykey"`
+ RepoDID string `gorm:"column:user_did;index;not null"`
+ // LivestreamURI ties this object to the place.stream.livestream it was
+ // recorded for, so live-to-VOD finalize can enumerate exactly the objects
+ // of one stream. May be empty for objects started before the URI was known.
+ LivestreamURI string `gorm:"column:livestream_uri;index"`
+ Bucket string `gorm:"column:bucket;not null"`
+ Key string `gorm:"column:key;not null"`
+ URL string `gorm:"column:url"`
+ StartedAt time.Time `gorm:"column:started_at"`
+ CompletedAt *time.Time `gorm:"column:completed_at"`
+ Size int64 `gorm:"column:size"`
+ PartCount int32 `gorm:"column:part_count"`
+ CreatedAt time.Time `gorm:"column:created_at"`
+ UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (s *S3Segment) TableName() string {
@@ -28,17 +32,18 @@ func (s *S3Segment) TableName() string {
// RecordStart inserts a new S3Segment row at the start of a multipart upload
// and returns its ID. Implements s3.Recorder.
-func (state *StatefulDB) RecordStart(ctx context.Context, repoDID, bucket, key string, started time.Time) (string, error) {
+func (state *StatefulDB) RecordStart(ctx context.Context, repoDID, bucket, key, livestreamURI string, started time.Time) (string, error) {
uu, err := uuid.NewV7()
if err != nil {
return "", err
}
seg := &S3Segment{
- ID: uu.String(),
- RepoDID: repoDID,
- Bucket: bucket,
- Key: key,
- StartedAt: started,
+ ID: uu.String(),
+ RepoDID: repoDID,
+ LivestreamURI: livestreamURI,
+ Bucket: bucket,
+ Key: key,
+ StartedAt: started,
}
if err := state.DB.WithContext(ctx).Create(seg).Error; err != nil {
return "", err
@@ -46,6 +51,23 @@ func (state *StatefulDB) RecordStart(ctx context.Context, repoDID, bucket, key s
return seg.ID, nil
}
+// ListS3SegmentsForLivestream returns the completed S3 objects recorded for one
+// livestream, ordered by StartedAt — i.e. the chronological byte order the
+// objects must be concatenated in to reconstruct the stream. In-progress
+// objects (CompletedAt == nil) are excluded; finalize runs after teardown, by
+// which point every object of a finished stream has completed.
+func (state *StatefulDB) ListS3SegmentsForLivestream(ctx context.Context, livestreamURI string) ([]S3Segment, error) {
+ var segs []S3Segment
+ err := state.DB.WithContext(ctx).
+ Where("livestream_uri = ? AND completed_at IS NOT NULL", livestreamURI).
+ Order("started_at ASC").
+ Find(&segs).Error
+ if err != nil {
+ return nil, err
+ }
+ return segs, nil
+}
+
// RecordComplete marks an S3 multipart upload as completed and records the
// final part count and size. Implements s3.Recorder.
func (state *StatefulDB) RecordComplete(ctx context.Context, id string, parts int32, size int64) error {
diff --git a/pkg/statedb/statedb.go b/pkg/statedb/statedb.go
index 32d7d18b..dc59bf1c 100644
--- a/pkg/statedb/statedb.go
+++ b/pkg/statedb/statedb.go
@@ -47,6 +47,10 @@ type StatefulDB struct {
// SetViewCountAggregator at bootstrap so pkg/statedb doesn't have
// to depend on the blob.Store-heavy pkg/viewlog.
viewCountAggregator ViewCountAggregator
+ // livestreamVODFinalizer concatenates a finished livestream's recorded
+ // MUXL objects into a VOD. Installed via SetLivestreamVODFinalizer at
+ // bootstrap, same indirection as vodProcessor.
+ livestreamVODFinalizer LivestreamVODFinalizer
}
// list tables here so we can migrate them
diff --git a/pkg/streamplace/mediafinalizeLivestream.go b/pkg/streamplace/mediafinalizeLivestream.go
new file mode 100644
index 00000000..bf451c9c
--- /dev/null
+++ b/pkg/streamplace/mediafinalizeLivestream.go
@@ -0,0 +1,33 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.media.finalizeLivestream
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// MediaFinalizeLivestream_Input is the input argument to a place.stream.media.finalizeLivestream call.
+type MediaFinalizeLivestream_Input struct {
+ // livestream: AT-URI of the place.stream.livestream record to finalize into a VOD. Must belong to the authenticated user.
+ Livestream string `json:"livestream" cborgen:"livestream"`
+}
+
+// MediaFinalizeLivestream_Output is the output of a place.stream.media.finalizeLivestream call.
+type MediaFinalizeLivestream_Output struct {
+ // uploadId: Identifier for the finalize job. Poll place.stream.media.getUploadStatus with it; once status is 'done', create the video with place.stream.media.publishVideo.
+ UploadId string `json:"uploadId" cborgen:"uploadId"`
+}
+
+// MediaFinalizeLivestream calls the XRPC method "place.stream.media.finalizeLivestream".
+func MediaFinalizeLivestream(ctx context.Context, c lexutil.LexClient, input *MediaFinalizeLivestream_Input) (*MediaFinalizeLivestream_Output, error) {
+ var out MediaFinalizeLivestream_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.media.finalizeLivestream", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/vod/finalize_livestream.go b/pkg/vod/finalize_livestream.go
new file mode 100644
index 00000000..467c58bc
--- /dev/null
+++ b/pkg/vod/finalize_livestream.go
@@ -0,0 +1,335 @@
+package vod
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/trace"
+
+ "stream.place/streamplace/pkg/bdasl"
+ "stream.place/streamplace/pkg/blob"
+ "stream.place/streamplace/pkg/config"
+ "stream.place/streamplace/pkg/log"
+ "stream.place/streamplace/pkg/media"
+ "stream.place/streamplace/pkg/muxl"
+ s3pkg "stream.place/streamplace/pkg/s3"
+ "stream.place/streamplace/pkg/statedb"
+)
+
+// FinalizeInput drives FinalizeLivestreamVOD. It's resolved by the bootstrap
+// from the FinalizeLivestreamVODTask payload (plus a model lookup for the
+// signing key), so this package needn't import pkg/model.
+type FinalizeInput struct {
+ // UploadID is the synthetic Upload row created by the finalizeLivestream
+ // procedure; the getUploadStatus/publishVideo client contract keys off it.
+ UploadID string
+ // RepoDID is the streamer whose repo the media.track records land in.
+ RepoDID string
+ // LivestreamURI selects which S3Segment objects belong to this stream.
+ LivestreamURI string
+ // SigningKey is the did:key whose private half C2PA-signed the live
+ // segments (the streamer's place.stream.key). Recorded on each
+ // place.stream.media.track so playback can verify signatures.
+ SigningKey string
+}
+
+// FinalizeLivestreamVOD turns a finished livestream's recorded MUXL objects
+// into a VOD, reusing the upload pipeline's tail half. The live segments are
+// already C2PA-signed canonical MUXL, so — unlike ProcessVOD — there is no
+// gstreamer remux and no re-signing: we concatenate the objects under one
+// synthesized init header into a content-addressed blob, derive the playback
+// sidecars from it with `muxl unwrap` (exactly as VOD transfer does), and
+// publish the same origin + track records.
+//
+// The content blob is shaped [init][segments…] — byte-structurally identical
+// to an uploaded VOD — so the metafile offset math is the proven path and a
+// live-derived VOD is indistinguishable from an uploaded one downstream
+// (playback, transfer, view-count). The bulk of the bytes are assembled
+// server-side via S3 UploadPartCopy; only the ~5 MB header part is uploaded.
+func FinalizeLivestreamVOD(ctx context.Context, cli *config.CLI, state *statedb.StatefulDB, store blob.Store, in FinalizeInput) (string, error) {
+ ctx = log.WithLogValues(ctx, "func", "FinalizeLivestreamVOD", "uploadId", in.UploadID, "did", in.RepoDID, "livestream", in.LivestreamURI)
+ ctx, span := vodTracer.Start(ctx, "vod.FinalizeLivestreamVOD", trace.WithAttributes(
+ attribute.String("upload_id", in.UploadID),
+ attribute.String("did", in.RepoDID),
+ attribute.String("livestream", in.LivestreamURI),
+ ))
+ defer span.End()
+
+ segs, err := state.ListS3SegmentsForLivestream(ctx, in.LivestreamURI)
+ if err != nil {
+ recordErr(span, "list_segments", err)
+ return "", fmt.Errorf("list s3 segments: %w", err)
+ }
+ if len(segs) == 0 {
+ err := fmt.Errorf("no recorded S3 segments for livestream %s", in.LivestreamURI)
+ recordErr(span, "list_segments", err)
+ return "", err
+ }
+ keys := make([]string, len(segs))
+ for i, s := range segs {
+ keys[i] = s.Key
+ }
+ span.SetAttributes(attribute.Int("object_count", len(keys)))
+ log.Log(ctx, "finalizing livestream VOD", "objects", len(keys))
+
+ // 1. Capture the canonical init header from the first object. We use the
+ // init `muxl unwrap` itself emits (rather than synthesizing one) so its
+ // length is exactly what the metafile builder assumes for a leading init —
+ // keeping the prepended-header blob's offsets self-consistent regardless of
+ // whether unwrap later passes the header through verbatim or re-derives it.
+ header, err := captureInitHeader(ctx, store, keys[0])
+ if err != nil {
+ recordErr(span, "capture_init", err)
+ return "", fmt.Errorf("capture init header: %w", err)
+ }
+ span.SetAttributes(attribute.Int("header_bytes", len(header)))
+
+ // 2. One pass over [header]+objects: hash for the CID + build the metafile
+ // (and per-track init blobs) via unwrap. The content blob is shaped exactly
+ // like an uploaded VOD, so the metafile offsets are the proven path.
+ cid, size, metafile, err := hashAndBuildMetafile(ctx, store, header, keys)
+ if err != nil {
+ recordErr(span, "build_metafile", err)
+ return "", err
+ }
+ contentKey := BlobsPrefix + cid + ".mp4"
+ span.SetAttributes(
+ attribute.String("cid", cid),
+ attribute.Int64("size_bytes", size),
+ attribute.String("content_key", contentKey),
+ )
+
+ // 3. Assemble the content blob, mostly server-side.
+ if err := runVODStage(ctx, "concat_assemble", func(ctx context.Context) error {
+ return assembleContentBlob(ctx, store, header, keys, contentKey)
+ }); err != nil {
+ recordErr(span, "concat_assemble", err)
+ return "", fmt.Errorf("assemble content blob: %w", err)
+ }
+
+ if err := runVODStage(ctx, stageMetafile, func(ctx context.Context) error {
+ return writeMetafile(ctx, store, cid, metafile)
+ }); err != nil {
+ recordErr(span, stageMetafile, err)
+ return "", fmt.Errorf("write metafile: %w", err)
+ }
+
+ // 4. Publish origin + track records and store TrackURIs on the Upload row,
+ // reusing the upload pipeline's publish path unchanged.
+ probe := metafileToVODResult(metafile)
+ if err := runVODStage(ctx, stagePublish, func(ctx context.Context) error {
+ return publishRecords(ctx, publishParams{
+ cli: cli,
+ state: state,
+ in: Input{UploadID: in.UploadID, RepoDID: in.RepoDID},
+ cid: cid,
+ size: size,
+ mimeType: "video/mp4",
+ probe: probe,
+ signingKey: in.SigningKey,
+ })
+ }); err != nil {
+ recordErr(span, stagePublish, err)
+ return "", fmt.Errorf("publish records: %w", err)
+ }
+
+ span.SetStatus(codes.Ok, "")
+ log.Log(ctx, "livestream VOD finalized", "cid", cid, "size", size, "objects", len(keys), "duration_ms", probe.DurationMS)
+ return cid, nil
+}
+
+// captureInitHeader runs `muxl unwrap` over just the first object and returns
+// the bytes of the single init event it emits, cancelling as soon as it has
+// them so we don't read the whole object. The returned header is a valid
+// ftyp+moov for the stream's catalog.
+func captureInitHeader(ctx context.Context, store blob.Store, key string) ([]byte, error) {
+ r, err := store.Open(ctx, key)
+ if err != nil {
+ return nil, fmt.Errorf("open first object %s: %w", key, err)
+ }
+ defer r.Close()
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ eventCh := make(chan *muxl.MuxlEvent, 16)
+ producerErr := make(chan error, 1)
+ go func() {
+ producerErr <- muxl.RunMuxlUnwrapEvents(ctx, io.NewSectionReader(r, 0, r.Size()), eventCh)
+ close(eventCh)
+ }()
+
+ var header []byte
+ for ev := range eventCh {
+ if header == nil && ev.Type == "init" {
+ header = append([]byte(nil), ev.Data...)
+ cancel() // we have the header; let the producer unwind, keep draining
+ }
+ }
+ // RunMuxlUnwrapEvents returns nil on cancellation, so a non-nil error here
+ // is a real parse failure, not our early stop.
+ if perr := <-producerErr; perr != nil && header == nil {
+ return nil, fmt.Errorf("muxl unwrap (init): %w", perr)
+ }
+ if header == nil {
+ return nil, errors.New("no init event emitted by first object")
+ }
+ return header, nil
+}
+
+// hashAndBuildMetafile streams [header]+objects once, teeing into a bdasl hasher
+// (for the content CID) and `muxl unwrap` → metafileBuilder (for the metafile +
+// per-track init blobs). Returns the CID, the total blob size, and the
+// finalized metafile.
+func hashAndBuildMetafile(ctx context.Context, store blob.Store, header []byte, keys []string) (string, int64, *Metafile, error) {
+ readers := make([]io.Reader, 0, len(keys)+1)
+ closers := make([]io.Closer, 0, len(keys))
+ defer func() {
+ for _, c := range closers {
+ _ = c.Close()
+ }
+ }()
+ readers = append(readers, bytes.NewReader(header))
+ for _, key := range keys {
+ r, err := store.Open(ctx, key)
+ if err != nil {
+ return "", 0, nil, fmt.Errorf("open object %s: %w", key, err)
+ }
+ closers = append(closers, r)
+ readers = append(readers, io.NewSectionReader(r, 0, r.Size()))
+ }
+
+ hasher := bdasl.NewWriter()
+ counter := &countingWriter{}
+ tee := io.TeeReader(io.MultiReader(readers...), io.MultiWriter(hasher, counter))
+
+ mb := newMetafileBuilder(ctx, store)
+ eventCh := make(chan *muxl.MuxlEvent, 16)
+ producerErr := make(chan error, 1)
+ go func() {
+ producerErr <- muxl.RunMuxlUnwrapEvents(ctx, tee, eventCh)
+ close(eventCh)
+ }()
+
+ var obsErr error
+ for ev := range eventCh {
+ if e := mb.Observe(ev); e != nil && obsErr == nil {
+ obsErr = e
+ }
+ }
+ if perr := <-producerErr; perr != nil {
+ return "", 0, nil, fmt.Errorf("muxl unwrap: %w", perr)
+ }
+ if obsErr != nil {
+ return "", 0, nil, fmt.Errorf("metafile build: %w", obsErr)
+ }
+ // Unwrap returns nil on context cancellation, which would otherwise let a
+ // truncated stream yield a partial metafile. Refuse to publish that.
+ if err := ctx.Err(); err != nil {
+ return "", 0, nil, err
+ }
+
+ cid := hasher.CID()
+ size := counter.load()
+ return cid, size, mb.Finalize(cid, size), nil
+}
+
+// assembleContentBlob writes header ++ objects to contentKey. For an S3 store
+// this is a near-entirely server-side concat (UploadPartCopy); a non-final
+// object below S3's 5 MB part floor (only short dev streams) falls back to a
+// full rewrite, as does any non-S3 store.
+func assembleContentBlob(ctx context.Context, store blob.Store, header []byte, keys []string, contentKey string) error {
+ if s3store, ok := store.(*blob.S3Store); ok {
+ err := s3pkg.ConcatWithHeader(ctx, s3store.Client(), s3store.Bucket(), header, keys, contentKey, "video/mp4")
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, s3pkg.ErrConcatPartTooSmall) {
+ return err
+ }
+ log.Warn(ctx, "s3 concat fell back to rewrite (object below 5MB part floor)", "key", contentKey)
+ }
+ return assembleViaWriter(ctx, store, header, keys, contentKey)
+}
+
+// assembleViaWriter streams header ++ objects through a store writer. The
+// universal fallback: correct for any blob.Store, but re-uploads every byte.
+func assembleViaWriter(ctx context.Context, store blob.Store, header []byte, keys []string, contentKey string) error {
+ w, err := store.NewWriter(ctx, contentKey, "video/mp4")
+ if err != nil {
+ return fmt.Errorf("open content writer: %w", err)
+ }
+ defer w.Close()
+ if _, err := w.Write(header); err != nil {
+ return fmt.Errorf("write header: %w", err)
+ }
+ for _, key := range keys {
+ r, err := store.Open(ctx, key)
+ if err != nil {
+ return fmt.Errorf("open object %s: %w", key, err)
+ }
+ _, err = io.Copy(w, io.NewSectionReader(r, 0, r.Size()))
+ _ = r.Close()
+ if err != nil {
+ return fmt.Errorf("copy object %s: %w", key, err)
+ }
+ }
+ return w.Complete()
+}
+
+// metafileToVODResult derives the probe metadata publishTrack needs from the
+// regenerated metafile, standing in for the gstreamer probe that an upload
+// would have produced. publishTrack hardcodes the video codec to h264 and only
+// uses width/height (+ optional framerate), so we leave framerate unset.
+func metafileToVODResult(meta *Metafile) media.VODResult {
+ var res media.VODResult
+ for _, t := range meta.Tracks {
+ switch t.Type {
+ case "video":
+ res.Video = &media.VODVideoTrack{
+ Codec: t.Codec,
+ Width: int(t.Width),
+ Height: int(t.Height),
+ }
+ case "audio":
+ res.Audio = &media.VODAudioTrack{
+ Codec: audioProbeCodec(t.Codec),
+ Rate: int(t.SampleRate),
+ Channels: int(t.Channels),
+ }
+ }
+ if d := metafileTrackDurationMS(t); d > res.DurationMS {
+ res.DurationMS = d
+ }
+ }
+ return res
+}
+
+// metafileTrackDurationMS sums a track's segment durations (in its timescale)
+// and converts to milliseconds.
+func metafileTrackDurationMS(t MetafileTrack) int64 {
+ if t.Timescale == 0 {
+ return 0
+ }
+ var ticks uint64
+ for _, s := range t.Segments {
+ ticks += s.DurationTicks
+ }
+ return int64(ticks * 1000 / uint64(t.Timescale))
+}
+
+// audioProbeCodec maps a metafile (CMAF catalog) audio codec name to the
+// gstreamer-caps-style value audioCodecForLexicon (publish.go) expects, so the
+// lexicon enum comes out right ("opus" vs "aac").
+func audioProbeCodec(metafileCodec string) string {
+ if strings.Contains(strings.ToLower(metafileCodec), "opus") {
+ return "x-opus"
+ }
+ return "mpeg" // audioCodecForLexicon maps "mpeg" -> "aac"
+}
diff --git a/pkg/vod/finalize_livestream_test.go b/pkg/vod/finalize_livestream_test.go
new file mode 100644
index 00000000..f1b8a5bc
--- /dev/null
+++ b/pkg/vod/finalize_livestream_test.go
@@ -0,0 +1,158 @@
+package vod
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "stream.place/streamplace/pkg/bdasl"
+ "stream.place/streamplace/pkg/blob"
+ "stream.place/streamplace/pkg/log"
+)
+
+// TestFinalizeHeaderConcat is the gating test for live-to-VOD finalize: it
+// proves that prepending the init `muxl unwrap` synthesizes for a stream's bare
+// signed segments produces a content blob whose regenerated metafile is
+// playback-correct — same per-track init CIDs and same per-segment sizes /
+// durations as the processing pipeline's, with self-consistent (contiguous,
+// header-offset) byte ranges.
+//
+// This is the assumption FinalizeLivestreamVOD rests on: the live S3 objects
+// are bare segments, and finalize prepends one synthesized header so the blob
+// is shaped [init][segments…] like an uploaded VOD. If muxl's bare-input init
+// synthesis ever diverged from what the metafile builder assumes for a leading
+// init, the byte ranges would be wrong and this fails.
+//
+// Like the other pkg/vod tests it needs gstreamer (warmGST/streamThroughMuxl),
+// so it runs in the cgo test containers, not a bare checkout.
+func TestFinalizeHeaderConcat(t *testing.T) {
+ warmGST()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ ctx = log.WithLogValues(ctx, "test", "TestFinalizeHeaderConcat")
+
+ fixture, err := os.ReadFile(getFixture("5sec.mp4"))
+ require.NoError(t, err)
+
+ signer, err := newUploadSigner(time.Now())
+ require.NoError(t, err)
+
+ // --- Ground truth: the processing pipeline's blob + metafile. ----------
+ storeA, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+
+ out := &bytes.Buffer{}
+ hasher := bdasl.NewWriter()
+ dst := teeWriter{hasher, out}
+ mbA := newMetafileBuilder(ctx, storeA)
+ _, err = streamThroughMuxl(ctx, bytes.NewReader(fixture), int64(len(fixture)), dst, mbA, signer.SignerInput)
+ require.NoError(t, err)
+ groundBlob := out.Bytes()
+ metaA := mbA.Finalize(hasher.CID(), int64(len(groundBlob)))
+ require.NotEmpty(t, metaA.Tracks)
+
+ // The processing blob is [init][segments…]; the live S3 objects would be
+ // the bare segments (no leading init). Strip the init to simulate them: it
+ // occupies [0, firstSegmentOffset).
+ initLen := minFirstOffset(t, metaA)
+ require.Greater(t, initLen, int64(0))
+ bareSegs := groundBlob[initLen:]
+
+ // --- Finalize path: capture header, prepend, regenerate. ---------------
+ storeB, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+
+ // Write the bare segments as the single recorded "object".
+ objKey := "live/obj0.m4s"
+ wObj, err := storeB.NewWriter(ctx, objKey, "video/iso.segment")
+ require.NoError(t, err)
+ _, err = wObj.Write(bareSegs)
+ require.NoError(t, err)
+ require.NoError(t, wObj.Complete())
+
+ // captureInitHeader is the real finalize helper: unwrap the bare object and
+ // take the init event it emits.
+ header, err := captureInitHeader(ctx, storeB, objKey)
+ require.NoError(t, err)
+ require.NotEmpty(t, header)
+
+ newBlob := append(append([]byte(nil), header...), bareSegs...)
+ cidB := func() string {
+ h := bdasl.NewWriter()
+ _, _ = h.Write(newBlob)
+ return h.CID()
+ }()
+ contentKey := BlobsPrefix + cidB + ".mp4"
+ wBlob, err := storeB.NewWriter(ctx, contentKey, "video/mp4")
+ require.NoError(t, err)
+ _, err = wBlob.Write(newBlob)
+ require.NoError(t, err)
+ require.NoError(t, wBlob.Complete())
+
+ metaB, initCIDs, err := regenerateSidecars(ctx, storeB, cidB, int64(len(newBlob)))
+ require.NoError(t, err)
+ require.NotEmpty(t, initCIDs)
+
+ // --- Correctness assertions. -------------------------------------------
+ require.Equal(t, len(metaA.Tracks), len(metaB.Tracks), "track count")
+ for tid, ta := range metaA.Tracks {
+ tb, ok := metaB.Tracks[tid]
+ require.Truef(t, ok, "track %s missing from regenerated metafile", tid)
+
+ // Per-track identity must match: same init blob, codec, geometry.
+ require.Equalf(t, ta.InitCID, tb.InitCID, "track %s initCID", tid)
+ require.Equalf(t, ta.Codec, tb.Codec, "track %s codec", tid)
+ require.Equalf(t, ta.Type, tb.Type, "track %s type", tid)
+ require.Equalf(t, ta.Timescale, tb.Timescale, "track %s timescale", tid)
+ require.Equalf(t, ta.Width, tb.Width, "track %s width", tid)
+ require.Equalf(t, ta.Height, tb.Height, "track %s height", tid)
+ require.Equalf(t, ta.Channels, tb.Channels, "track %s channels", tid)
+ require.Equalf(t, ta.SampleRate, tb.SampleRate, "track %s sampleRate", tid)
+
+ // Per-segment sizes/durations must match exactly; offsets may differ
+ // only if the synthesized header length differs from the pipeline init.
+ require.Equalf(t, len(ta.Segments), len(tb.Segments), "track %s segment count", tid)
+ for i := range ta.Segments {
+ require.Equalf(t, ta.Segments[i].Size, tb.Segments[i].Size, "track %s seg %d size", tid, i)
+ require.Equalf(t, ta.Segments[i].DurationTicks, tb.Segments[i].DurationTicks, "track %s seg %d duration", tid, i)
+ require.Equalf(t, ta.Segments[i].SampleCount, tb.Segments[i].SampleCount, "track %s seg %d sampleCount", tid, i)
+ }
+ }
+
+ // The regenerated metafile must be self-consistent with the actual blob:
+ // the earliest segment starts exactly after the header, and each track's
+ // segments tile contiguously.
+ require.Equal(t, int64(len(header)), minFirstOffset(t, metaB), "first segment must start right after the header")
+ for tid, tb := range metaB.Tracks {
+ for i := 1; i < len(tb.Segments); i++ {
+ prev := tb.Segments[i-1]
+ require.Equalf(t, prev.Offset+prev.Size, tb.Segments[i].Offset,
+ "track %s seg %d not contiguous", tid, i)
+ }
+ }
+
+ // Sanity: the prepended-header blob round-trips to its own CID, and the
+ // header length matches the pipeline's init length (so a live VOD is
+ // byte-structurally identical to an uploaded one).
+ require.Equal(t, initLen, int64(len(header)), "synthesized header length should match the pipeline init length")
+}
+
+// minFirstOffset returns the smallest first-segment offset across all tracks —
+// i.e. where the first segment byte begins, which equals the leading init's
+// length in a [init][segments…] blob.
+func minFirstOffset(t *testing.T, m *Metafile) int64 {
+ t.Helper()
+ var min int64 = -1
+ for _, tr := range m.Tracks {
+ require.NotEmpty(t, tr.Segments)
+ off := tr.Segments[0].Offset
+ if min < 0 || off < min {
+ min = off
+ }
+ }
+ return min
+}
--
2.51.2
From c1bc2d7c449c9dc6fa1dc2d8248d6813056c4a97 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 7 Jun 2026 11:53:21 -0700
Subject: [PATCH 02/34] live-to-vod: use muxl wrap --init-only
---
pkg/vod/finalize_livestream.go | 43 ++++++++++++----------------------
1 file changed, 15 insertions(+), 28 deletions(-)
diff --git a/pkg/vod/finalize_livestream.go b/pkg/vod/finalize_livestream.go
index 467c58bc..ef551216 100644
--- a/pkg/vod/finalize_livestream.go
+++ b/pkg/vod/finalize_livestream.go
@@ -144,10 +144,15 @@ func FinalizeLivestreamVOD(ctx context.Context, cli *config.CLI, state *statedb.
return cid, nil
}
-// captureInitHeader runs `muxl unwrap` over just the first object and returns
-// the bytes of the single init event it emits, cancelling as soon as it has
-// them so we don't read the whole object. The returned header is a valid
-// ftyp+moov for the stream's catalog.
+// captureInitHeader synthesizes the per-stream init segment (ftyp+moov) from
+// the first recorded object via `muxl wrap --init-only`. The live objects are
+// bare canonical segments whose embedded catalogs carry everything needed to
+// derive the init; prepending this header to their concatenation yields the
+// proven [init][segments…] VOD blob shape.
+//
+// RunMuxlWrapInit writes straight to a buffer (no event channel), so — unlike
+// an unwrap pass — there's no stdout pipe to back up and no mid-stream cancel,
+// which is what previously deadlocked here.
func captureInitHeader(ctx context.Context, store blob.Store, key string) ([]byte, error) {
r, err := store.Open(ctx, key)
if err != nil {
@@ -155,32 +160,14 @@ func captureInitHeader(ctx context.Context, store blob.Store, key string) ([]byt
}
defer r.Close()
- ctx, cancel := context.WithCancel(ctx)
- defer cancel()
-
- eventCh := make(chan *muxl.MuxlEvent, 16)
- producerErr := make(chan error, 1)
- go func() {
- producerErr <- muxl.RunMuxlUnwrapEvents(ctx, io.NewSectionReader(r, 0, r.Size()), eventCh)
- close(eventCh)
- }()
-
- var header []byte
- for ev := range eventCh {
- if header == nil && ev.Type == "init" {
- header = append([]byte(nil), ev.Data...)
- cancel() // we have the header; let the producer unwind, keep draining
- }
- }
- // RunMuxlUnwrapEvents returns nil on cancellation, so a non-nil error here
- // is a real parse failure, not our early stop.
- if perr := <-producerErr; perr != nil && header == nil {
- return nil, fmt.Errorf("muxl unwrap (init): %w", perr)
+ var buf bytes.Buffer
+ if err := muxl.RunMuxlWrapInit(ctx, io.NewSectionReader(r, 0, r.Size()), &buf); err != nil {
+ return nil, fmt.Errorf("synthesize init header: %w", err)
}
- if header == nil {
- return nil, errors.New("no init event emitted by first object")
+ if buf.Len() == 0 {
+ return nil, errors.New("muxl produced an empty init header")
}
- return header, nil
+ return buf.Bytes(), nil
}
// hashAndBuildMetafile streams [header]+objects once, teeing into a bdasl hasher
--
2.51.2
From fe2af967d25b371b3d105d59ac866095722ffcad Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 7 Jun 2026 11:59:57 -0700
Subject: [PATCH 03/34] live-to-vod: fix segment-contiguity assertion in
finalize gating test
The blob is [init] followed by per-segment events that interleave all
tracks' chunks, so a single track's segments are intentionally NOT
contiguous (the other track's chunk sits between them). Assert the
correct invariant instead: all chunks across all tracks, ordered by
offset, tile the blob with no gaps from len(header) to the blob's end.
Verified in the streamplace-2 cgo container: TestFinalizeHeaderConcat
and TestRegenerateSidecars both pass.
Co-Authored-By: Claude Opus 4.8
---
pkg/vod/finalize_livestream_test.go | 43 +++++++++++++++++++----------
1 file changed, 29 insertions(+), 14 deletions(-)
diff --git a/pkg/vod/finalize_livestream_test.go b/pkg/vod/finalize_livestream_test.go
index f1b8a5bc..22c57a08 100644
--- a/pkg/vod/finalize_livestream_test.go
+++ b/pkg/vod/finalize_livestream_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"os"
+ "sort"
"testing"
"time"
@@ -123,22 +124,36 @@ func TestFinalizeHeaderConcat(t *testing.T) {
}
}
- // The regenerated metafile must be self-consistent with the actual blob:
- // the earliest segment starts exactly after the header, and each track's
- // segments tile contiguously.
- require.Equal(t, int64(len(header)), minFirstOffset(t, metaB), "first segment must start right after the header")
- for tid, tb := range metaB.Tracks {
- for i := 1; i < len(tb.Segments); i++ {
- prev := tb.Segments[i-1]
- require.Equalf(t, prev.Offset+prev.Size, tb.Segments[i].Offset,
- "track %s seg %d not contiguous", tid, i)
+ // The regenerated metafile must be self-consistent with the actual blob.
+ // The blob is [header] followed by per-segment events, each holding all
+ // tracks' chunks interleaved (so a SINGLE track's segments are not
+ // contiguous — the other track's chunks sit between them). The correct
+ // invariant is that ALL chunks across ALL tracks, ordered by offset, tile
+ // the blob with no gaps or overlaps: starting right after the header and
+ // ending at the blob's end.
+ type chunk struct{ off, size int64 }
+ var chunks []chunk
+ for _, tb := range metaB.Tracks {
+ for _, s := range tb.Segments {
+ chunks = append(chunks, chunk{s.Offset, s.Size})
}
}
-
- // Sanity: the prepended-header blob round-trips to its own CID, and the
- // header length matches the pipeline's init length (so a live VOD is
- // byte-structurally identical to an uploaded one).
- require.Equal(t, initLen, int64(len(header)), "synthesized header length should match the pipeline init length")
+ sort.Slice(chunks, func(i, j int) bool { return chunks[i].off < chunks[j].off })
+ require.NotEmpty(t, chunks)
+ cursor := int64(len(header))
+ for i, c := range chunks {
+ require.Equalf(t, cursor, c.off, "chunk %d must tile contiguously after the header", i)
+ cursor += c.size
+ }
+ require.Equal(t, int64(len(newBlob)), cursor, "chunks must cover the whole blob exactly")
+
+ // The synthesized header (muxl wrap --init-only) should match the pipeline's
+ // init length, making a live VOD byte-structurally identical to an uploaded
+ // one. Not a correctness requirement (offsets above already tile against the
+ // actual header), so log rather than fail if muxl ever diverges.
+ if initLen != int64(len(header)) {
+ t.Logf("synthesized header length %d != pipeline init length %d (still self-consistent)", len(header), initLen)
+ }
}
// minFirstOffset returns the smallest first-segment offset across all tracks —
--
2.51.2
From b649388cf2e204d158df65c97c8d1484dc191e06 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 7 Jun 2026 13:12:11 -0700
Subject: [PATCH 04/34] vod: emit EXT-X-DISCONTINUITY across concatenated
reconnect boundaries
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A recording that concatenates multiple ingest sessions (the streamer
disconnected/reconnected or stopped and restarted) has segments whose
decode time (tfdt) restarts near zero mid-stream. The HLS media playlist
is one continuous EXTINF timeline with a single EXT-X-MAP and byte ranges
into one blob, so without any discontinuity signal a player can't
reconcile the backward jump and chokes.
Detect and signal it:
- metafile builder parses each segment's tfdt (baseMediaDecodeTime) and
flags MetafileSegment.Discontinuity when a track's tfdt jumps backward
vs its previous segment. A clean single-session recording's tfdt is
strictly increasing, so this never fires there. (Confirmed empirically:
Event.Number is always 0 from unwrap, and a same-codec reconnect emits
no second init event — tfdt is the only signal.)
- mediaPlaylist emits #EXT-X-DISCONTINUITY before a flagged segment, and
#EXT-X-DISCONTINUITY-SEQUENCE when a clip is trimmed to/after a boundary.
This is the same-codec case (timestamp/sequence reset); a mid-stream codec
change would also need a fresh EXT-X-MAP per session, not handled here.
Keeps the original live signatures intact (no re-MUXL).
Verified in the streamplace-2 cgo container: TestMetafileFlagsReconnect-
Discontinuity (synthetic reconnect -> exactly one flagged segment per
track at the boundary) and TestMediaPlaylist_Discontinuity[TrimmedToBoundary]
(playlist emits the tag / sequence); pkg/vod + pkg/spxrpc suites pass.
Co-Authored-By: Claude Opus 4.8
---
pkg/spxrpc/place_stream_playback_getvideo.go | 41 +++++++--
.../place_stream_playback_getvideo_test.go | 57 +++++++++++++
pkg/vod/discontinuity_test.go | 85 +++++++++++++++++++
pkg/vod/metafile.go | 70 +++++++++++++++
4 files changed, 247 insertions(+), 6 deletions(-)
create mode 100644 pkg/vod/discontinuity_test.go
diff --git a/pkg/spxrpc/place_stream_playback_getvideo.go b/pkg/spxrpc/place_stream_playback_getvideo.go
index cafa8db8..ccd5e7c1 100644
--- a/pkg/spxrpc/place_stream_playback_getvideo.go
+++ b/pkg/spxrpc/place_stream_playback_getvideo.go
@@ -636,7 +636,7 @@ func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID, sid, cdnURL string, st
if !ok {
return "", echo.NewHTTPError(http.StatusNotFound, "TrackNotFound")
}
- segments := filterSegments(t.Segments, t.Timescale, startMS, endMS)
+ segments, discontinuitySeq := filterSegments(t.Segments, t.Timescale, startMS, endMS)
var maxDurSec float64
for _, s := range segments {
@@ -660,11 +660,26 @@ func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID, sid, cdnURL string, st
"#EXT-X-INDEPENDENT-SEGMENTS",
fmt.Sprintf("#EXT-X-TARGETDURATION:%d", targetDuration),
"#EXT-X-MEDIA-SEQUENCE:0",
+ }
+ // EXT-X-DISCONTINUITY-SEQUENCE accounts for discontinuities trimmed off the
+ // front of a clipped playlist (the first served segment being a boundary, or
+ // boundaries before it). Omitted when zero, which is the common case.
+ if discontinuitySeq > 0 {
+ lines = append(lines, fmt.Sprintf("#EXT-X-DISCONTINUITY-SEQUENCE:%d", discontinuitySeq))
+ }
+ lines = append(lines,
fmt.Sprintf(`#EXT-X-MAP:URI=%q`, blobURL(cdnURL, ownerDID, t.InitCID, sid)),
"",
- }
+ )
bURL := blobURL(cdnURL, ownerDID, t.BlobCID, sid)
- for _, seg := range segments {
+ for i, seg := range segments {
+ // A segment whose decode time reset (a concatenated reconnect/restart)
+ // begins a new timeline; signal it so players re-anchor instead of
+ // choking on the backward jump. The first served segment's own boundary
+ // is folded into EXT-X-DISCONTINUITY-SEQUENCE above, not an inline tag.
+ if seg.Discontinuity && i > 0 {
+ lines = append(lines, "#EXT-X-DISCONTINUITY")
+ }
durSec := float64(seg.DurationTicks) / float64(t.Timescale)
lines = append(lines,
fmt.Sprintf("#EXTINF:%.6f,", durSec),
@@ -719,9 +734,13 @@ func composeClipBounds(clipStartMS int64, clipEndMS, queryStartMS, queryEndMS *i
// 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 {
+// It also returns the discontinuity sequence: the number of discontinuity
+// boundaries at or before the first returned segment (i.e. trimmed off the
+// front), which the caller emits as EXT-X-DISCONTINUITY-SEQUENCE so a clipped
+// playlist's timeline stays correct.
+func filterSegments(segments []vod.MetafileSegment, timescale uint32, startMS, endMS *int64) ([]vod.MetafileSegment, int) {
if startMS == nil && endMS == nil {
- return segments
+ return segments, 0
}
tsf := float64(timescale)
startTicks := int64(0)
@@ -735,14 +754,24 @@ func filterSegments(segments []vod.MetafileSegment, timescale uint32, startMS, e
out := segments[:0:0]
cursor := int64(0)
+ discontinuitySeq := 0
+ started := false
for _, seg := range segments {
segEnd := cursor + int64(seg.DurationTicks)
if segEnd > startTicks && cursor < endTicks {
+ // The first served segment being itself a boundary is reflected in
+ // the discontinuity sequence rather than an inline tag.
+ if !started && seg.Discontinuity {
+ discontinuitySeq++
+ }
+ started = true
out = append(out, seg)
+ } else if !started && seg.Discontinuity {
+ discontinuitySeq++
}
cursor = segEnd
}
- return out
+ return out, discontinuitySeq
}
// computeBandwidth derives an approximate average bitrate (bits/s)
diff --git a/pkg/spxrpc/place_stream_playback_getvideo_test.go b/pkg/spxrpc/place_stream_playback_getvideo_test.go
index be7dfca6..4559bf91 100644
--- a/pkg/spxrpc/place_stream_playback_getvideo_test.go
+++ b/pkg/spxrpc/place_stream_playback_getvideo_test.go
@@ -124,6 +124,63 @@ func TestMediaPlaylist_Video(t *testing.T) {
require.Contains(t, pl, `#EXT-X-BYTERANGE:1800@2100`)
require.Contains(t, pl, "#EXTINF:1.000000,")
require.Contains(t, pl, "#EXT-X-ENDLIST")
+ // A clean single-session VOD has no discontinuities.
+ require.NotContains(t, pl, "#EXT-X-DISCONTINUITY")
+}
+
+// reconnectVideoMetafile is a 3-segment video track whose middle segment is
+// flagged as a discontinuity — the metafile shape produced by a recording that
+// concatenates two ingest sessions (a reconnect), where session 2's decode
+// time reset. Each segment is 1s (durationTicks == timescale).
+func reconnectVideoMetafile() *vod.Metafile {
+ return &vod.Metafile{
+ BlobCID: "bafyblob",
+ BlobSize: 10_000,
+ Tracks: map[string]vod.MetafileTrack{
+ "1": {
+ Type: "video",
+ Codec: "avc1.64002a",
+ Timescale: 6000,
+ InitCID: "bafyvideoinit",
+ BlobCID: "bafyblob",
+ BlobSize: 10_000,
+ Width: 1920,
+ Height: 1080,
+ Segments: []vod.MetafileSegment{
+ {Offset: 100, Size: 2000, DurationTicks: 6000, SampleCount: 60},
+ {Offset: 2100, Size: 1800, DurationTicks: 6000, SampleCount: 60, Discontinuity: true},
+ {Offset: 3900, Size: 1700, DurationTicks: 6000, SampleCount: 60},
+ },
+ },
+ },
+ }
+}
+
+func TestMediaPlaylist_Discontinuity(t *testing.T) {
+ pl, err := mediaPlaylist(reconnectVideoMetafile(), "1", fixtureDID, fixtureSID, "", nil, nil)
+ require.NoError(t, err)
+ // Exactly one inline EXT-X-DISCONTINUITY (its own line — distinct from the
+ // EXT-X-DISCONTINUITY-SEQUENCE header), and no sequence header for full play.
+ require.Equal(t, 1, strings.Count(pl, "#EXT-X-DISCONTINUITY\n"))
+ require.NotContains(t, pl, "#EXT-X-DISCONTINUITY-SEQUENCE")
+ // It must sit after the first segment and immediately before the flagged one.
+ discIdx := strings.Index(pl, "#EXT-X-DISCONTINUITY\n")
+ firstSeg := strings.Index(pl, "#EXT-X-BYTERANGE:2000@100")
+ flaggedSeg := strings.Index(pl, "#EXT-X-BYTERANGE:1800@2100")
+ require.Greater(t, discIdx, firstSeg, "discontinuity must come after the first segment")
+ require.Less(t, discIdx, flaggedSeg, "discontinuity must come right before the flagged segment")
+}
+
+func TestMediaPlaylist_DiscontinuityTrimmedToBoundary(t *testing.T) {
+ start := int64(1000) // ms — segment index 1 (the boundary) starts at 1s
+ pl, err := mediaPlaylist(reconnectVideoMetafile(), "1", fixtureDID, fixtureSID, "", &start, nil)
+ require.NoError(t, err)
+ // The boundary is now the first served segment: reflected as a sequence
+ // bump, NOT an inline tag.
+ require.Contains(t, pl, "#EXT-X-DISCONTINUITY-SEQUENCE:1")
+ require.Equal(t, 0, strings.Count(pl, "#EXT-X-DISCONTINUITY\n"))
+ require.NotContains(t, pl, "#EXT-X-BYTERANGE:2000@100", "pre-boundary segment should be trimmed")
+ require.Contains(t, pl, "#EXT-X-BYTERANGE:1800@2100", "boundary segment should be served")
}
func TestMediaPlaylist_UnknownTrack(t *testing.T) {
diff --git a/pkg/vod/discontinuity_test.go b/pkg/vod/discontinuity_test.go
new file mode 100644
index 00000000..03c4b541
--- /dev/null
+++ b/pkg/vod/discontinuity_test.go
@@ -0,0 +1,85 @@
+package vod
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "stream.place/streamplace/pkg/bdasl"
+ "stream.place/streamplace/pkg/blob"
+ "stream.place/streamplace/pkg/log"
+)
+
+// TestMetafileFlagsReconnectDiscontinuity proves the metafile builder flags a
+// concatenated reconnect. It synthesizes a "reconnect" recording by processing
+// the fixture twice and concatenating session 2's bare segments after session
+// 1's full [init][segments]; session 2's segments restart their tfdt at ~0,
+// exactly like a streamer who disconnected/reconnected. Regenerating the
+// sidecars from that blob must flag exactly one discontinuity per track — the
+// first segment of session 2 — and never the very first segment.
+//
+// Needs gstreamer (warmGST/streamThroughMuxl), so it runs in the cgo test
+// containers, not a bare checkout.
+func TestMetafileFlagsReconnectDiscontinuity(t *testing.T) {
+ warmGST()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ ctx = log.WithLogValues(ctx, "test", "TestMetafileFlagsReconnectDiscontinuity")
+
+ fixture, err := os.ReadFile(getFixture("5sec.mp4"))
+ require.NoError(t, err)
+ signer, err := newUploadSigner(time.Now())
+ require.NoError(t, err)
+
+ buildSession := func() (b []byte, initLen int64) {
+ out := &bytes.Buffer{}
+ hasher := bdasl.NewWriter()
+ dst := teeWriter{hasher, out}
+ st, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+ mb := newMetafileBuilder(ctx, st)
+ _, err = streamThroughMuxl(ctx, bytes.NewReader(fixture), int64(len(fixture)), dst, mb, signer.SignerInput)
+ require.NoError(t, err)
+ blob := out.Bytes()
+ meta := mb.Finalize(hasher.CID(), int64(len(blob)))
+ return blob, minFirstOffset(t, meta)
+ }
+
+ s1, _ := buildSession()
+ s2, s2InitLen := buildSession()
+ reconnect := append(append([]byte(nil), s1...), s2[s2InitLen:]...)
+
+ // Regenerate the sidecars from the concatenated blob, exactly as finalize
+ // (and VOD transfer) do.
+ store, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+ h := bdasl.NewWriter()
+ _, _ = h.Write(reconnect)
+ cid := h.CID()
+ w, err := store.NewWriter(ctx, BlobsPrefix+cid+".mp4", "video/mp4")
+ require.NoError(t, err)
+ _, err = w.Write(reconnect)
+ require.NoError(t, err)
+ require.NoError(t, w.Complete())
+
+ meta, _, err := regenerateSidecars(ctx, store, cid, int64(len(reconnect)))
+ require.NoError(t, err)
+ require.NotEmpty(t, meta.Tracks)
+
+ for tid, tr := range meta.Tracks {
+ var discIdx []int
+ for i, s := range tr.Segments {
+ if s.Discontinuity {
+ discIdx = append(discIdx, i)
+ }
+ }
+ t.Logf("track %s: %d segments, discontinuities at %v", tid, len(tr.Segments), discIdx)
+ require.Lenf(t, discIdx, 1, "track %s should have exactly one discontinuity (the reconnect boundary)", tid)
+ require.Greaterf(t, discIdx[0], 0, "track %s discontinuity must not be the first segment", tid)
+ require.Falsef(t, tr.Segments[0].Discontinuity, "track %s first segment must not be flagged", tid)
+ }
+}
diff --git a/pkg/vod/metafile.go b/pkg/vod/metafile.go
index ec9e0a44..1529152c 100644
--- a/pkg/vod/metafile.go
+++ b/pkg/vod/metafile.go
@@ -2,6 +2,7 @@ package vod
import (
"context"
+ "encoding/binary"
"encoding/json"
"fmt"
"sort"
@@ -16,6 +17,44 @@ import (
"stream.place/streamplace/pkg/muxl"
)
+// firstTFDT walks the ISO-BMFF boxes of a segment chunk (per-track moof+mdat,
+// possibly prefixed by c2pa/muxl uuid boxes) and returns the
+// baseMediaDecodeTime from the first tfdt box it finds. Minimal walker:
+// recurses into moof/traf containers and skips everything else. ok=false if no
+// tfdt is present or a box uses 64-bit/extends-to-EOF sizing (not expected in
+// canonical MUXL segments).
+func firstTFDT(box []byte) (uint64, bool) {
+ for len(box) >= 8 {
+ size := int(binary.BigEndian.Uint32(box[0:4]))
+ typ := string(box[4:8])
+ if size < 8 || size > len(box) {
+ return 0, false
+ }
+ payload := box[8:size]
+ switch typ {
+ case "moof", "traf":
+ if v, ok := firstTFDT(payload); ok {
+ return v, true
+ }
+ case "tfdt":
+ if len(payload) >= 1 {
+ switch payload[0] { // version
+ case 0:
+ if len(payload) >= 8 {
+ return uint64(binary.BigEndian.Uint32(payload[4:8])), true
+ }
+ case 1:
+ if len(payload) >= 12 {
+ return binary.BigEndian.Uint64(payload[4:12]), true
+ }
+ }
+ }
+ }
+ box = box[size:]
+ }
+ return 0, false
+}
+
// Metafile is the per-blob HLS playback index emitted alongside a
// processed VOD blob. JSON shape mirrors what `muxl hls` produces (see
// /home/iameli/code/muxl/src/hls.rs:write_metadata_json) — the playback
@@ -55,6 +94,15 @@ type MetafileSegment struct {
Size int64 `json:"size"`
DurationTicks uint64 `json:"durationTicks"`
SampleCount uint32 `json:"sampleCount"`
+ // Discontinuity marks a segment that begins a new continuous timeline —
+ // its decode time jumped backward relative to the previous segment of the
+ // same track. This happens when a recording concatenates multiple ingest
+ // sessions (the streamer disconnected/reconnected or stopped and restarted),
+ // each restarting its tfdt near zero. The HLS playlist generator emits an
+ // EXT-X-DISCONTINUITY before such a segment so players re-anchor the
+ // timeline instead of choking on the backward jump. Omitted (false) for the
+ // common single-session case.
+ Discontinuity bool `json:"discontinuity,omitempty"`
}
// metafileBuilder consumes the rich event stream from the muxl
@@ -75,6 +123,12 @@ type metafileBuilder struct {
runningOffset int64 // bytes written to the concatenated output so far
seenInit bool
+
+ // lastTFDT / tfdtSeen track each track's previous baseMediaDecodeTime so a
+ // backward jump (a concatenated reconnect/restart) can be flagged as a
+ // discontinuity. See MetafileSegment.Discontinuity.
+ lastTFDT map[string]uint64
+ tfdtSeen map[string]bool
}
func newMetafileBuilder(ctx context.Context, store blob.Store) *metafileBuilder {
@@ -83,6 +137,8 @@ func newMetafileBuilder(ctx context.Context, store blob.Store) *metafileBuilder
store: store,
trackInitCIDs: map[string]string{},
trackSegments: map[string][]MetafileSegment{},
+ lastTFDT: map[string]uint64{},
+ tfdtSeen: map[string]bool{},
}
}
@@ -127,11 +183,25 @@ func (b *metafileBuilder) Observe(ev *muxl.MuxlEvent) error {
sort.Strings(keys)
for _, tid := range keys {
chunk := ev.Tracks[tid]
+ // Flag a discontinuity when this track's decode time jumps backward
+ // vs its previous segment — the signature of a concatenated
+ // reconnect/restart. A normal stream's tfdt is strictly increasing
+ // (tfdt[n] = tfdt[n-1] + duration[n-1]), so this never fires for a
+ // clean single-session recording.
+ disc := false
+ if tfdt, ok := firstTFDT(chunk); ok {
+ if b.tfdtSeen[tid] && tfdt < b.lastTFDT[tid] {
+ disc = true
+ }
+ b.lastTFDT[tid] = tfdt
+ b.tfdtSeen[tid] = true
+ }
b.trackSegments[tid] = append(b.trackSegments[tid], MetafileSegment{
Offset: b.runningOffset,
Size: int64(len(chunk)),
DurationTicks: ev.Durations[tid],
SampleCount: ev.SampleCounts[tid],
+ Discontinuity: disc,
})
b.runningOffset += int64(len(chunk))
}
--
2.51.2
From 1cd17cd0fb0c17f4a7fe222d83f3acd5de3e05bf Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 7 Jun 2026 15:27:27 -0700
Subject: [PATCH 05/34] s3: roll over the live upload object when the
livestream changes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A single continuous ingest moves through several place.stream.livestream
records — each "update livestream" mints a NEW record (chapter markers),
baked into subsequent segments' manifests. The S3 uploader stamped each
object with one livestream URI at object-start, so an object could straddle
a chapter change; finalize(newChapter) then matched no object and returned
"no segments found".
Cut a fresh object the moment the livestream URI changes (alongside the
existing time-based cutover), so every object belongs to exactly one
livestream. finalize(URI) is unchanged and now selects exactly that
chapter's objects. This also composes with multi-node: a livestream that
spans two nodes' StreamSessions tags both nodes' objects with the same
shared URI into the shared bucket/statedb, so finalize coalesces them into
one VOD (the reconnect's tfdt reset in the middle is the clean
EXT-X-DISCONTINUITY case). Per-record VODs for now; a cross-record playlist
can stitch a whole multi-chapter stream later.
Also: object keys gain a per-uploader sequence number so a same-second
rollover (now possible with per-chapter cutover) can't collide/overwrite;
and the upload loop's S3 calls go through an injectable interface so the
rollover is unit-tested with a fake client.
Co-Authored-By: Claude Opus 4.8
---
pkg/s3/s3.go | 71 ++++++++++++++++++--------
pkg/s3/uploader_test.go | 109 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 158 insertions(+), 22 deletions(-)
diff --git a/pkg/s3/s3.go b/pkg/s3/s3.go
index 7d4c0f17..008ee1cc 100644
--- a/pkg/s3/s3.go
+++ b/pkg/s3/s3.go
@@ -35,6 +35,15 @@ type Recorder interface {
RecordComplete(ctx context.Context, id string, parts int32, size int64) error
}
+// uploadAPI is the subset of *s3.Client the upload loop uses. Pulled out so
+// tests can inject a fake; *s3.Client satisfies it.
+type uploadAPI interface {
+ CreateMultipartUpload(context.Context, *s3.CreateMultipartUploadInput, ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error)
+ UploadPart(context.Context, *s3.UploadPartInput, ...func(*s3.Options)) (*s3.UploadPartOutput, error)
+ CompleteMultipartUpload(context.Context, *s3.CompleteMultipartUploadInput, ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error)
+ AbortMultipartUpload(context.Context, *s3.AbortMultipartUploadInput, ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error)
+}
+
// S3Uploader manages streaming multipart uploads to an S3-compatible endpoint.
// Bare canonical MUXL segments are fed via AddSegment and written verbatim —
// no per-object init header — so the objects of one stream concatenate
@@ -43,7 +52,7 @@ type Recorder interface {
// what lets it "concat fearlessly". Every cutoverEvery, the current upload is
// completed and a new one begins.
type S3Uploader struct {
- client *s3.Client
+ client uploadAPI
bucket string
cutoverEvery time.Duration
keyPrefix string // e.g. "did:plc:abc123/"
@@ -60,9 +69,13 @@ type S3Uploader struct {
closed atomic.Bool
}
-// SetLivestreamURI records the livestream this stream's objects belong to. It
-// may be called once the URI is resolved (it can be unknown when the uploader
-// starts); subsequent multipart objects are stamped with it via RecordStart.
+// SetLivestreamURI records the livestream this stream's objects belong to. A
+// single continuous ingest can move through several place.stream.livestream
+// records (each "update livestream" mints a new one — chapter markers), so when
+// this changes the upload loop rolls over to a fresh object tagged with the new
+// URI. That keeps every object within a single livestream, which is what lets
+// finalize select exactly one livestream's objects (and, across nodes, coalesce
+// them by the shared URI). It may be called before the URI is first resolved.
func (u *S3Uploader) SetLivestreamURI(uri string) {
u.mu.Lock()
u.livestreamURI = uri
@@ -79,14 +92,15 @@ func (u *S3Uploader) getLivestreamURI() string {
const minPartSize = 5 * 1024 * 1024
type activeUpload struct {
- key string
- uploadID string
- recordID string // set by Recorder.RecordStart, used for RecordComplete
- parts []types.CompletedPart
- partNum int32
- started time.Time
- buf []byte // accumulates segments until we hit minPartSize
- totalSize int64 // running total of bytes flushed across all parts
+ key string
+ uploadID string
+ recordID string // set by Recorder.RecordStart, used for RecordComplete
+ livestreamURI string // the livestream this object belongs to; a change rolls it over
+ parts []types.CompletedPart
+ partNum int32
+ started time.Time
+ buf []byte // accumulates segments until we hit minPartSize
+ totalSize int64 // running total of bytes flushed across all parts
}
var DefaultCutoverEvery = 10 * time.Minute
@@ -97,7 +111,6 @@ var DefaultCutoverEvery = 10 * time.Minute
// nil to disable persistence. Starts the muxl Concatenator and a background
// goroutine that reads processed segments and uploads them.
func NewS3Uploader(cfg Config, userDID, keyPrefix string, cutoverEvery time.Duration, recorder Recorder) *S3Uploader {
- ctx := context.Background()
client := s3.New(s3.Options{
Region: cfg.Region,
Credentials: credentials.NewStaticCredentialsProvider(
@@ -108,12 +121,18 @@ func NewS3Uploader(cfg Config, userDID, keyPrefix string, cutoverEvery time.Dura
BaseEndpoint: aws.String(cfg.Endpoint),
UsePathStyle: true,
})
+ return newS3Uploader(client, cfg.Bucket, userDID, keyPrefix, cutoverEvery, recorder)
+}
+
+// newS3Uploader is the client-injectable constructor behind NewS3Uploader; the
+// fake-client tests use it directly.
+func newS3Uploader(client uploadAPI, bucket, userDID, keyPrefix string, cutoverEvery time.Duration, recorder Recorder) *S3Uploader {
if cutoverEvery == 0 {
cutoverEvery = DefaultCutoverEvery
}
u := &S3Uploader{
client: client,
- bucket: cfg.Bucket,
+ bucket: bucket,
cutoverEvery: cutoverEvery,
keyPrefix: keyPrefix,
userDID: userDID,
@@ -121,7 +140,7 @@ func NewS3Uploader(cfg Config, userDID, keyPrefix string, cutoverEvery time.Dura
done: make(chan error, 1),
recorder: recorder,
}
- go u.uploadLoop(ctx)
+ go u.uploadLoop(context.Background())
return u
}
@@ -168,10 +187,12 @@ func (u *S3Uploader) Close(ctx context.Context) error {
func (u *S3Uploader) uploadLoop(ctx context.Context) {
ctx = log.WithLogValues(ctx, "func", "s3.uploadLoop")
var current *activeUpload
+ objSeq := 0 // disambiguates keys when two objects roll over within one second
startUpload := func() error {
+ objSeq++
now := time.Now()
- key := fmt.Sprintf("%s%s.m4s", u.keyPrefix, now.UTC().Format("2006-01-02T15-04-05"))
+ key := fmt.Sprintf("%s%s-%d.m4s", u.keyPrefix, now.UTC().Format("2006-01-02T15-04-05"), objSeq)
resp, err := u.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(u.bucket),
@@ -182,13 +203,15 @@ func (u *S3Uploader) uploadLoop(ctx context.Context) {
return fmt.Errorf("creating multipart upload for %s: %w", key, err)
}
+ uri := u.getLivestreamURI()
current = &activeUpload{
- key: key,
- uploadID: *resp.UploadId,
- started: now,
+ key: key,
+ uploadID: *resp.UploadId,
+ started: now,
+ livestreamURI: uri,
}
if u.recorder != nil {
- id, recErr := u.recorder.RecordStart(ctx, u.userDID, u.bucket, key, u.getLivestreamURI(), now)
+ id, recErr := u.recorder.RecordStart(ctx, u.userDID, u.bucket, key, uri, now)
if recErr != nil {
log.Error(ctx, "recording S3 upload start", "key", key, "error", recErr)
}
@@ -268,8 +291,12 @@ func (u *S3Uploader) uploadLoop(ctx context.Context) {
handleSegment := func(seg []byte) error {
now := time.Now()
- // Cut over if needed
- if current != nil && now.Sub(current.started) >= u.cutoverEvery {
+ // Roll over to a new object when the current one has run for cutoverEvery,
+ // or when the livestream changed (a new place.stream.livestream "chapter"
+ // record). Cutting over on the livestream change keeps each object within
+ // a single livestream so finalize can select exactly one livestream's
+ // objects without one straddling two chapters.
+ if current != nil && (now.Sub(current.started) >= u.cutoverEvery || current.livestreamURI != u.getLivestreamURI()) {
if err := completeUpload(); err != nil {
return err
}
diff --git a/pkg/s3/uploader_test.go b/pkg/s3/uploader_test.go
index 8324cd63..69a498e0 100644
--- a/pkg/s3/uploader_test.go
+++ b/pkg/s3/uploader_test.go
@@ -2,11 +2,120 @@ package s3
import (
"context"
+ "fmt"
"sync"
"testing"
"time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/stretchr/testify/require"
)
+// fakeUploadAPI is an in-memory stand-in for the multipart subset of *s3.Client
+// the upload loop drives. It hands back dummy upload IDs / etags and never errs.
+type fakeUploadAPI struct {
+ mu sync.Mutex
+ creates int
+}
+
+func (f *fakeUploadAPI) CreateMultipartUpload(_ context.Context, _ *awss3.CreateMultipartUploadInput, _ ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error) {
+ f.mu.Lock()
+ f.creates++
+ n := f.creates
+ f.mu.Unlock()
+ return &awss3.CreateMultipartUploadOutput{UploadId: aws.String(fmt.Sprintf("up-%d", n))}, nil
+}
+
+func (f *fakeUploadAPI) UploadPart(_ context.Context, _ *awss3.UploadPartInput, _ ...func(*awss3.Options)) (*awss3.UploadPartOutput, error) {
+ return &awss3.UploadPartOutput{ETag: aws.String("etag")}, nil
+}
+
+func (f *fakeUploadAPI) CompleteMultipartUpload(_ context.Context, _ *awss3.CompleteMultipartUploadInput, _ ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error) {
+ return &awss3.CompleteMultipartUploadOutput{}, nil
+}
+
+func (f *fakeUploadAPI) AbortMultipartUpload(_ context.Context, _ *awss3.AbortMultipartUploadInput, _ ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) {
+ return &awss3.AbortMultipartUploadOutput{}, nil
+}
+
+// fakeRecorder captures the (key, livestreamURI) of every started object.
+type fakeRecorder struct {
+ mu sync.Mutex
+ keys []string
+ uris []string
+ starts int
+}
+
+func (r *fakeRecorder) RecordStart(_ context.Context, _, _, key, livestreamURI string, _ time.Time) (string, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.keys = append(r.keys, key)
+ r.uris = append(r.uris, livestreamURI)
+ r.starts++
+ return fmt.Sprintf("rec-%d", r.starts), nil
+}
+
+func (r *fakeRecorder) RecordComplete(_ context.Context, _ string, _ int32, _ int64) error {
+ return nil
+}
+
+func (r *fakeRecorder) count() int { r.mu.Lock(); defer r.mu.Unlock(); return r.starts }
+
+func (r *fakeRecorder) startURIs() []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]string(nil), r.uris...)
+}
+
+func (r *fakeRecorder) startKeys() []string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]string(nil), r.keys...)
+}
+
+func waitForStarts(t *testing.T, rec *fakeRecorder, n int) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if rec.count() >= n {
+ return
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %d RecordStart calls (got %d)", n, rec.count())
+}
+
+// TestS3UploaderCutoverOnLivestreamChange proves the uploader rolls over to a
+// fresh object the moment the livestream URI changes (a new "chapter" record),
+// so each object belongs to exactly one livestream — which is what lets finalize
+// select one livestream's objects. cutoverEvery is set huge so ONLY the
+// livestream change can trigger the rollover.
+func TestS3UploaderCutoverOnLivestreamChange(t *testing.T) {
+ fc := &fakeUploadAPI{}
+ rec := &fakeRecorder{}
+ u := newS3Uploader(fc, "bucket", "did:plc:test", "did:plc:test/", time.Hour, rec)
+
+ ctx := context.Background()
+ seg := make([]byte, 1024) // well under minPartSize: buffered until the object completes
+
+ u.SetLivestreamURI("at://A")
+ require.NoError(t, u.AddSegment(ctx, seg))
+ waitForStarts(t, rec, 1) // object 1, livestream A
+
+ u.SetLivestreamURI("at://B")
+ require.NoError(t, u.AddSegment(ctx, seg))
+ waitForStarts(t, rec, 2) // livestream changed -> object 2, livestream B
+
+ require.NoError(t, u.Close(ctx))
+
+ require.Equal(t, []string{"at://A", "at://B"}, rec.startURIs(),
+ "each object must be tagged with the livestream active when it started")
+ keys := rec.startKeys()
+ require.Len(t, keys, 2)
+ require.NotEqual(t, keys[0], keys[1], "rolled-over objects must have distinct keys")
+}
+
// TestS3UploaderCloseIdempotent exercises the lifecycle fix that re-enabled
// live S3 upload: Close must be safe to call repeatedly and concurrently (it
// was a plain close(segCh) before, which panicked on the second call), and
--
2.51.2
From b23fdf60b78f90cb7a2d6f8476b63b496bb7567d Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 7 Jun 2026 17:00:40 -0700
Subject: [PATCH 06/34] blob: PrefixReader for flat-MP4 VOD serving
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The serving primitive for the forthcoming flat-MP4 VOD path: present a
synthesized faststart MP4 header followed by a byte-range window of a
canonical MUXL blob as a single blob.Reader, so it drops straight into the
existing Range/serveBlobRange machinery.
muxl's flat-header synthesis owns all absolute offsets (the moov's co64
already points into the [header][body] address space), so we serve exactly
header ++ body[off:off+len] — the whole blob for a full VOD, a contiguous
sub-range for a clip. No muxl dependency yet; this is the load-bearing
read primitive the playback endpoint will wrap once muxl ships the
metafile -> flat-header synth.
Co-Authored-By: Claude Opus 4.8
---
pkg/blob/prefix_reader.go | 84 ++++++++++++++++++++++++++++++++++
pkg/blob/prefix_reader_test.go | 70 ++++++++++++++++++++++++++++
2 files changed, 154 insertions(+)
create mode 100644 pkg/blob/prefix_reader.go
create mode 100644 pkg/blob/prefix_reader_test.go
diff --git a/pkg/blob/prefix_reader.go b/pkg/blob/prefix_reader.go
new file mode 100644
index 00000000..bd623416
--- /dev/null
+++ b/pkg/blob/prefix_reader.go
@@ -0,0 +1,84 @@
+package blob
+
+import (
+ "errors"
+ "io"
+)
+
+// PrefixReader presents header bytes followed by a byte-range window of an
+// underlying blob.Reader as a single blob.Reader (io.ReaderAt + Size + Close).
+//
+// It's the serving primitive for flat-MP4 VOD: a synthesized faststart MP4
+// header concatenated with the canonical MUXL blob's body bytes. muxl's
+// flat-header synthesis owns all absolute offsets — the moov's co64 entries
+// already point into the [header][body] address space — so we serve exactly
+// header ++ body[bodyOffset : bodyOffset+bodyLen] and hand it to the same Range
+// machinery the raw blob uses. For a whole VOD the window is the entire blob;
+// for a clip it's the contiguous sub-range muxl sized the header for.
+type PrefixReader struct {
+ header []byte
+ body Reader
+ bodyOffset int64 // start of the window within body
+ bodyLen int64 // length of the window
+}
+
+// NewPrefixReader builds a Reader over header ++ body[bodyOffset:bodyOffset+bodyLen].
+// It takes ownership of body: Close closes it.
+func NewPrefixReader(header []byte, body Reader, bodyOffset, bodyLen int64) *PrefixReader {
+ return &PrefixReader{header: header, body: body, bodyOffset: bodyOffset, bodyLen: bodyLen}
+}
+
+func (r *PrefixReader) Size() int64 { return int64(len(r.header)) + r.bodyLen }
+
+func (r *PrefixReader) Close() error { return r.body.Close() }
+
+// ReadAt implements io.ReaderAt over the virtual [header][body-window] file.
+func (r *PrefixReader) ReadAt(p []byte, off int64) (int, error) {
+ if off < 0 {
+ return 0, errors.New("blob.PrefixReader: negative offset")
+ }
+ total := r.Size()
+ if off >= total {
+ return 0, io.EOF
+ }
+
+ // Trim the request to what's actually available; a request running past the
+ // end reports io.EOF once the available bytes are delivered.
+ eof := false
+ if avail := total - off; int64(len(p)) > avail {
+ p = p[:avail]
+ eof = true
+ }
+
+ n := 0
+ h := int64(len(r.header))
+
+ // Header portion.
+ if off < h {
+ hn := h - off
+ if hn > int64(len(p)) {
+ hn = int64(len(p))
+ }
+ copy(p[:hn], r.header[off:off+hn])
+ n += int(hn)
+ off += hn
+ p = p[hn:]
+ }
+
+ // Body window portion.
+ if len(p) > 0 {
+ m, err := r.body.ReadAt(p, r.bodyOffset+(off-h))
+ n += m
+ if err != nil && !errors.Is(err, io.EOF) {
+ return n, err
+ }
+ if errors.Is(err, io.EOF) {
+ return n, io.EOF
+ }
+ }
+
+ if eof {
+ return n, io.EOF
+ }
+ return n, nil
+}
diff --git a/pkg/blob/prefix_reader_test.go b/pkg/blob/prefix_reader_test.go
new file mode 100644
index 00000000..d1852111
--- /dev/null
+++ b/pkg/blob/prefix_reader_test.go
@@ -0,0 +1,70 @@
+package blob
+
+import (
+ "bytes"
+ "io"
+ "testing"
+)
+
+// bytesBlob is a blob.Reader backed by an in-memory byte slice.
+type bytesBlob struct{ b []byte }
+
+func (r bytesBlob) ReadAt(p []byte, off int64) (int, error) {
+ return bytes.NewReader(r.b).ReadAt(p, off)
+}
+func (r bytesBlob) Size() int64 { return int64(len(r.b)) }
+func (r bytesBlob) Close() error { return nil }
+
+func TestPrefixReader(t *testing.T) {
+ header := []byte("HEADER!") // 7 bytes
+ body := bytesBlob{[]byte("0123456789")} // 10 bytes
+ r := NewPrefixReader(header, body, 2, 5) // window "23456"
+ const want = "HEADER!23456" // 12 bytes
+
+ if got := r.Size(); got != int64(len(want)) {
+ t.Fatalf("Size = %d, want %d", got, len(want))
+ }
+
+ // Full read via SectionReader (exercises ReadAt across the boundary).
+ all, err := io.ReadAll(io.NewSectionReader(r, 0, r.Size()))
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ if string(all) != want {
+ t.Fatalf("ReadAll = %q, want %q", all, want)
+ }
+
+ cases := []struct {
+ name string
+ off, n int64
+ want string
+ wantEOF bool
+ }{
+ {"header only", 0, 4, "HEAD", false},
+ {"header into body", 4, 6, "ER!234", false},
+ {"body only", 7, 3, "234", false},
+ {"exact tail", 9, 3, "456", false},
+ {"past end trims + EOF", 10, 5, "56", true},
+ {"start at last byte", 11, 1, "6", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ buf := make([]byte, tc.n)
+ got, err := r.ReadAt(buf, tc.off)
+ if string(buf[:got]) != tc.want {
+ t.Fatalf("ReadAt(%d,%d) = %q, want %q", tc.off, tc.n, buf[:got], tc.want)
+ }
+ if tc.wantEOF && err != io.EOF {
+ t.Fatalf("ReadAt(%d,%d) err = %v, want io.EOF", tc.off, tc.n, err)
+ }
+ if !tc.wantEOF && err != nil {
+ t.Fatalf("ReadAt(%d,%d) err = %v, want nil", tc.off, tc.n, err)
+ }
+ })
+ }
+
+ // Reading exactly at the end is io.EOF with no bytes.
+ if n, err := r.ReadAt(make([]byte, 4), r.Size()); n != 0 || err != io.EOF {
+ t.Fatalf("ReadAt at end = (%d,%v), want (0, EOF)", n, err)
+ }
+}
--
2.51.2
From 21682a34036e62ffb4cdeceff71ada29ddd1a3fd Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 9 Jun 2026 09:14:30 -0700
Subject: [PATCH 07/34] vod: swap to flat mp4 storage format
---
go.mod | 2 +
pkg/muxl/muxl.go | 37 +++++
pkg/spxrpc/place_stream_playback_getvideo.go | 4 +-
pkg/vod/finalize_livestream.go | 106 +++----------
pkg/vod/flat_header_test.go | 100 ++++++++++++
pkg/vod/flat_vod.go | 117 ++++++++++++++
pkg/vod/flat_vod_test.go | 158 +++++++++++++++++++
pkg/vod/metafile.go | 45 +++++-
pkg/vod/per_segment_metafile_test.go | 104 ++++++++++++
pkg/vod/process.go | 99 +++++++-----
pkg/vod/thumbnail_test.go | 2 +-
pkg/vod/transfer.go | 17 ++
12 files changed, 659 insertions(+), 132 deletions(-)
create mode 100644 pkg/vod/flat_header_test.go
create mode 100644 pkg/vod/flat_vod.go
create mode 100644 pkg/vod/flat_vod_test.go
create mode 100644 pkg/vod/per_segment_metafile_test.go
diff --git a/go.mod b/go.mod
index 9a232e56..8b2d6a42 100644
--- a/go.mod
+++ b/go.mod
@@ -578,3 +578,5 @@ require (
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect
star-tex.org/x/tex v0.7.1 // indirect
)
+
+replace github.com/streamplace/muxl/go => /home/iameli/code/muxl/go
diff --git a/pkg/muxl/muxl.go b/pkg/muxl/muxl.go
index 29db6bec..30ec98fa 100644
--- a/pkg/muxl/muxl.go
+++ b/pkg/muxl/muxl.go
@@ -101,6 +101,43 @@ func RunMuxlWrapInit(ctx context.Context, input io.Reader, output io.Writer) err
return eng.WrapInit(ctx, input, output)
}
+// RunMuxlMetafiles reads a stored MUXL wrapper (canonical fMP4 / flat / bare)
+// and writes the payload-free metafile stream — one init then one segment per
+// canonical .m4s — as versioned DRISL. Streamplace archives these bytes
+// verbatim and feeds them back to RunMuxlSynthesizeFlatHeader.
+func RunMuxlMetafiles(ctx context.Context, input io.Reader, output io.Writer) error {
+ eng, err := getEngine()
+ if err != nil {
+ return err
+ }
+ return eng.Metafiles(ctx, input, output)
+}
+
+// RunMuxlMetafile returns the payload-free metafile for ONE signed canonical
+// segment (muxl metafile --no-init) — the per-fragment archive unit, emitted at
+// sign time. The init metafile (catalog) is obtained separately (it's the
+// prefix of a RunMuxlMetafiles stream over the first GoP).
+func RunMuxlMetafile(ctx context.Context, segment []byte) ([]byte, error) {
+ eng, err := getEngine()
+ if err != nil {
+ return nil, err
+ }
+ return eng.Metafile(ctx, segment)
+}
+
+// RunMuxlSynthesizeFlatHeader synthesizes a faststart MP4 header (ftyp + moov +
+// mdat-envelope) from a metafile stream (init + N segments). The header's co64
+// offsets are absolute over the [header][body] layout, so serving
+// header ++ yields a valid flat MP4 — no base
+// offset to pass; muxl owns all the offset math.
+func RunMuxlSynthesizeFlatHeader(ctx context.Context, metafiles io.Reader, output io.Writer) error {
+ eng, err := getEngine()
+ if err != nil {
+ return err
+ }
+ return eng.SynthesizeFlatHeader(ctx, metafiles, output)
+}
+
// RunMuxlVerify validates the C2PA/S2PA signatures on a signed MUXL wrapper and
// returns the per-segment manifest+cert+validation JSON document.
func RunMuxlVerify(ctx context.Context, input io.Reader) (string, error) {
diff --git a/pkg/spxrpc/place_stream_playback_getvideo.go b/pkg/spxrpc/place_stream_playback_getvideo.go
index ccd5e7c1..8e8c2d59 100644
--- a/pkg/spxrpc/place_stream_playback_getvideo.go
+++ b/pkg/spxrpc/place_stream_playback_getvideo.go
@@ -683,7 +683,9 @@ func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID, sid, cdnURL string, st
durSec := float64(seg.DurationTicks) / float64(t.Timescale)
lines = append(lines,
fmt.Sprintf("#EXTINF:%.6f,", durSec),
- fmt.Sprintf("#EXT-X-BYTERANGE:%d@%d", seg.Size, seg.Offset),
+ // Segment offsets are fragment-relative; the canonical fragments sit
+ // behind the synthesized flat-MP4 header in the blob, so shift by it.
+ fmt.Sprintf("#EXT-X-BYTERANGE:%d@%d", seg.Size, seg.Offset+meta.FlatHeaderSize),
bURL,
)
}
diff --git a/pkg/vod/finalize_livestream.go b/pkg/vod/finalize_livestream.go
index ef551216..ae2f133c 100644
--- a/pkg/vod/finalize_livestream.go
+++ b/pkg/vod/finalize_livestream.go
@@ -12,7 +12,6 @@ import (
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
- "stream.place/streamplace/pkg/bdasl"
"stream.place/streamplace/pkg/blob"
"stream.place/streamplace/pkg/config"
"stream.place/streamplace/pkg/log"
@@ -78,58 +77,56 @@ func FinalizeLivestreamVOD(ctx context.Context, cli *config.CLI, state *statedb.
span.SetAttributes(attribute.Int("object_count", len(keys)))
log.Log(ctx, "finalizing livestream VOD", "objects", len(keys))
- // 1. Capture the canonical init header from the first object. We use the
- // init `muxl unwrap` itself emits (rather than synthesizing one) so its
- // length is exactly what the metafile builder assumes for a leading init —
- // keeping the prepended-header blob's offsets self-consistent regardless of
- // whether unwrap later passes the header through verbatim or re-derives it.
- header, err := captureInitHeader(ctx, store, keys[0])
+ // 1. Synthesize the flat-MP4 faststart header from the fragments' metafile.
+ flatHeader, err := synthFlatHeaderForObjects(ctx, store, keys)
if err != nil {
- recordErr(span, "capture_init", err)
- return "", fmt.Errorf("capture init header: %w", err)
+ recordErr(span, "synth_header", err)
+ return "", fmt.Errorf("synthesize flat header: %w", err)
}
- span.SetAttributes(attribute.Int("header_bytes", len(header)))
+ span.SetAttributes(attribute.Int("flat_header_bytes", len(flatHeader)))
- // 2. One pass over [header]+objects: hash for the CID + build the metafile
- // (and per-track init blobs) via unwrap. The content blob is shaped exactly
- // like an uploaded VOD, so the metafile offsets are the proven path.
- cid, size, metafile, err := hashAndBuildMetafile(ctx, store, header, keys)
+ // 2. Hash the bare fragments → MUXL CID (the stable, header-independent id)
+ // and build the fragment-relative HLS metafile + per-track init blobs.
+ muxlCID, fragSize, metafile, err := hashAndBuildFragmentMetafile(ctx, store, keys)
if err != nil {
recordErr(span, "build_metafile", err)
return "", err
}
- contentKey := BlobsPrefix + cid + ".mp4"
+ metafile.FlatHeaderSize = int64(len(flatHeader))
+ blobSize := int64(len(flatHeader)) + fragSize
+ contentKey := BlobsPrefix + muxlCID + ".mp4"
span.SetAttributes(
- attribute.String("cid", cid),
- attribute.Int64("size_bytes", size),
+ attribute.String("muxl_cid", muxlCID),
+ attribute.Int64("blob_size", blobSize),
attribute.String("content_key", contentKey),
)
- // 3. Assemble the content blob, mostly server-side.
+ // 3. Assemble [flat-header][fragments] at blobs/.mp4 (the flat-header
+ // is uploaded, the fragments server-side-copied after it).
if err := runVODStage(ctx, "concat_assemble", func(ctx context.Context) error {
- return assembleContentBlob(ctx, store, header, keys, contentKey)
+ return assembleContentBlob(ctx, store, flatHeader, keys, contentKey)
}); err != nil {
recordErr(span, "concat_assemble", err)
return "", fmt.Errorf("assemble content blob: %w", err)
}
if err := runVODStage(ctx, stageMetafile, func(ctx context.Context) error {
- return writeMetafile(ctx, store, cid, metafile)
+ return writeMetafile(ctx, store, muxlCID, metafile)
}); err != nil {
recordErr(span, stageMetafile, err)
return "", fmt.Errorf("write metafile: %w", err)
}
- // 4. Publish origin + track records and store TrackURIs on the Upload row,
- // reusing the upload pipeline's publish path unchanged.
+ // 4. Publish origin + track records (referencing the MUXL CID) and store
+ // TrackURIs on the Upload row, reusing the upload publish path unchanged.
probe := metafileToVODResult(metafile)
if err := runVODStage(ctx, stagePublish, func(ctx context.Context) error {
return publishRecords(ctx, publishParams{
cli: cli,
state: state,
in: Input{UploadID: in.UploadID, RepoDID: in.RepoDID},
- cid: cid,
- size: size,
+ cid: muxlCID,
+ size: blobSize,
mimeType: "video/mp4",
probe: probe,
signingKey: in.SigningKey,
@@ -140,8 +137,8 @@ func FinalizeLivestreamVOD(ctx context.Context, cli *config.CLI, state *statedb.
}
span.SetStatus(codes.Ok, "")
- log.Log(ctx, "livestream VOD finalized", "cid", cid, "size", size, "objects", len(keys), "duration_ms", probe.DurationMS)
- return cid, nil
+ log.Log(ctx, "livestream VOD finalized", "muxlCid", muxlCID, "blobSize", blobSize, "objects", len(keys), "duration_ms", probe.DurationMS)
+ return muxlCID, nil
}
// captureInitHeader synthesizes the per-stream init segment (ftyp+moov) from
@@ -170,63 +167,6 @@ func captureInitHeader(ctx context.Context, store blob.Store, key string) ([]byt
return buf.Bytes(), nil
}
-// hashAndBuildMetafile streams [header]+objects once, teeing into a bdasl hasher
-// (for the content CID) and `muxl unwrap` → metafileBuilder (for the metafile +
-// per-track init blobs). Returns the CID, the total blob size, and the
-// finalized metafile.
-func hashAndBuildMetafile(ctx context.Context, store blob.Store, header []byte, keys []string) (string, int64, *Metafile, error) {
- readers := make([]io.Reader, 0, len(keys)+1)
- closers := make([]io.Closer, 0, len(keys))
- defer func() {
- for _, c := range closers {
- _ = c.Close()
- }
- }()
- readers = append(readers, bytes.NewReader(header))
- for _, key := range keys {
- r, err := store.Open(ctx, key)
- if err != nil {
- return "", 0, nil, fmt.Errorf("open object %s: %w", key, err)
- }
- closers = append(closers, r)
- readers = append(readers, io.NewSectionReader(r, 0, r.Size()))
- }
-
- hasher := bdasl.NewWriter()
- counter := &countingWriter{}
- tee := io.TeeReader(io.MultiReader(readers...), io.MultiWriter(hasher, counter))
-
- mb := newMetafileBuilder(ctx, store)
- eventCh := make(chan *muxl.MuxlEvent, 16)
- producerErr := make(chan error, 1)
- go func() {
- producerErr <- muxl.RunMuxlUnwrapEvents(ctx, tee, eventCh)
- close(eventCh)
- }()
-
- var obsErr error
- for ev := range eventCh {
- if e := mb.Observe(ev); e != nil && obsErr == nil {
- obsErr = e
- }
- }
- if perr := <-producerErr; perr != nil {
- return "", 0, nil, fmt.Errorf("muxl unwrap: %w", perr)
- }
- if obsErr != nil {
- return "", 0, nil, fmt.Errorf("metafile build: %w", obsErr)
- }
- // Unwrap returns nil on context cancellation, which would otherwise let a
- // truncated stream yield a partial metafile. Refuse to publish that.
- if err := ctx.Err(); err != nil {
- return "", 0, nil, err
- }
-
- cid := hasher.CID()
- size := counter.load()
- return cid, size, mb.Finalize(cid, size), nil
-}
-
// assembleContentBlob writes header ++ objects to contentKey. For an S3 store
// this is a near-entirely server-side concat (UploadPartCopy); a non-final
// object below S3's 5 MB part floor (only short dev streams) falls back to a
diff --git a/pkg/vod/flat_header_test.go b/pkg/vod/flat_header_test.go
new file mode 100644
index 00000000..fe453675
--- /dev/null
+++ b/pkg/vod/flat_header_test.go
@@ -0,0 +1,100 @@
+package vod
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "stream.place/streamplace/pkg/bdasl"
+ "stream.place/streamplace/pkg/blob"
+ "stream.place/streamplace/pkg/log"
+ "stream.place/streamplace/pkg/muxl"
+)
+
+// TestFlatHeaderServing is the end-to-end proof of the flat-MP4 VOD path on the
+// streamplace side: from a finalize-shaped canonical blob ([init][signed
+// segments]), emit metafiles, synthesize a faststart header, and show that
+// serving header ++ via blob.PrefixReader
+// reproduces a real flat MP4 byte-for-byte. It also pins down where the flat
+// body sits relative to the canonical blob (the bodyOffset the endpoint feeds
+// PrefixReader).
+//
+// Needs gstreamer (warmGST) and the new muxl (Metafiles/SynthesizeFlatHeader),
+// so it runs in the cgo container with the local muxl replace.
+func TestFlatHeaderServing(t *testing.T) {
+ warmGST()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ ctx = log.WithLogValues(ctx, "test", "TestFlatHeaderServing")
+
+ fixture, err := os.ReadFile(getFixture("5sec.mp4"))
+ require.NoError(t, err)
+ signer, err := newUploadSigner(time.Now())
+ require.NoError(t, err)
+
+ // Build a canonical VOD blob exactly like finalize: [init][signed segments].
+ store, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+ out := &bytes.Buffer{}
+ hasher := bdasl.NewWriter()
+ dst := teeWriter{hasher, out}
+ mb := newMetafileBuilder(ctx, store)
+ _, err = streamThroughMuxl(ctx, bytes.NewReader(fixture), int64(len(fixture)), dst, mb, signer.SignerInput)
+ require.NoError(t, err)
+ canon := out.Bytes()
+ meta := mb.Finalize(hasher.CID(), int64(len(canon)))
+ initLen := minFirstOffset(t, meta)
+
+ // Emit metafiles, then synthesize the flat header from them.
+ var metas bytes.Buffer
+ require.NoError(t, muxl.RunMuxlMetafiles(ctx, bytes.NewReader(canon), &metas))
+ require.NotZero(t, metas.Len(), "Metafiles produced no output")
+ var hdr bytes.Buffer
+ require.NoError(t, muxl.RunMuxlSynthesizeFlatHeader(ctx, bytes.NewReader(metas.Bytes()), &hdr))
+ require.NotZero(t, hdr.Len(), "SynthesizeFlatHeader produced no output")
+
+ // Oracle: the real flat MP4 muxl writes from the same canonical blob.
+ var oracle bytes.Buffer
+ require.NoError(t, muxl.RunMuxlWrap(ctx, bytes.NewReader(canon), "flat", &oracle))
+
+ // The synthesized header is the flat MP4's prefix; the remainder is the body.
+ require.True(t, bytes.HasPrefix(oracle.Bytes(), hdr.Bytes()),
+ "synthesized header (%d) must be a prefix of the flat MP4 (%d)", hdr.Len(), oracle.Len())
+ body := oracle.Bytes()[hdr.Len():]
+
+ // The body must be a contiguous tail range of the canonical blob.
+ idx := bytes.Index(canon, body)
+ require.GreaterOrEqual(t, idx, 0, "flat body must be a contiguous range of the canonical blob")
+ require.Equal(t, len(canon), idx+len(body), "flat body must run to the end of the canonical blob")
+ t.Logf("flat body offset in canonical blob = %d (initLen=%d, blobSize=%d, headerLen=%d)",
+ idx, initLen, len(canon), hdr.Len())
+
+ // Serve header ++ canonical-blob[idx:] via PrefixReader over the STORED blob
+ // (exactly what the playback endpoint will do) and prove it's byte-identical
+ // to the real flat MP4.
+ require.NoError(t, writeBlob(ctx, store, "canon.mp4", canon))
+ r, err := store.Open(ctx, "canon.mp4")
+ require.NoError(t, err)
+ defer r.Close()
+ pr := blob.NewPrefixReader(hdr.Bytes(), r, int64(idx), int64(len(body)))
+ served, err := io.ReadAll(io.NewSectionReader(pr, 0, pr.Size()))
+ require.NoError(t, err)
+ require.Equal(t, oracle.Bytes(), served, "PrefixReader must serve the byte-exact flat MP4")
+}
+
+func writeBlob(ctx context.Context, store blob.Store, key string, b []byte) error {
+ w, err := store.NewWriter(ctx, key, "video/mp4")
+ if err != nil {
+ return err
+ }
+ defer w.Close()
+ if _, err := w.Write(b); err != nil {
+ return err
+ }
+ return w.Complete()
+}
diff --git a/pkg/vod/flat_vod.go b/pkg/vod/flat_vod.go
new file mode 100644
index 00000000..73f3bc1a
--- /dev/null
+++ b/pkg/vod/flat_vod.go
@@ -0,0 +1,117 @@
+package vod
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+
+ "stream.place/streamplace/pkg/bdasl"
+ "stream.place/streamplace/pkg/blob"
+ "stream.place/streamplace/pkg/muxl"
+)
+
+// The flat-MP4 VOD model: the stored blob is [flat-header][canonical fragments],
+// content-addressed by the MUXL CID — the BDASL hash of the fragments ALONE,
+// not the assembled blob. Keying off the immutable fragments means a better
+// flat-header can be re-synthesized and rewritten in place without changing a
+// VOD's id (or any media.origin/media.track record), and one blob serves both
+// flat-MP4 (the whole file) and HLS (per-track init blobs + byte-ranges into
+// the fragments at flatHeaderSize+offset).
+
+// openFragmentReaders opens the canonical fragment objects as sequential
+// readers in order. The caller closes the returned closers.
+func openFragmentReaders(ctx context.Context, store blob.Store, keys []string) ([]io.Reader, []io.Closer, error) {
+ readers := make([]io.Reader, 0, len(keys))
+ closers := make([]io.Closer, 0, len(keys))
+ for _, key := range keys {
+ r, err := store.Open(ctx, key)
+ if err != nil {
+ for _, c := range closers {
+ _ = c.Close()
+ }
+ return nil, nil, fmt.Errorf("open object %s: %w", key, err)
+ }
+ closers = append(closers, r)
+ readers = append(readers, io.NewSectionReader(r, 0, r.Size()))
+ }
+ return readers, closers, nil
+}
+
+func closeAll(closers []io.Closer) {
+ for _, c := range closers {
+ _ = c.Close()
+ }
+}
+
+// hashAndBuildFragmentMetafile streams the bare canonical fragments once,
+// teeing into a bdasl hasher (→ the MUXL CID) and muxl unwrap → a
+// fragment-relative metafileBuilder (→ the HLS metafile + per-track init
+// blobs). The MUXL CID hashes the fragments alone, so it's stable across
+// header re-synthesis. Returns the MUXL CID, the fragments' total size, and the
+// metafile (with fragment-relative segment offsets).
+func hashAndBuildFragmentMetafile(ctx context.Context, store blob.Store, keys []string) (string, int64, *Metafile, error) {
+ readers, closers, err := openFragmentReaders(ctx, store, keys)
+ if err != nil {
+ return "", 0, nil, err
+ }
+ defer closeAll(closers)
+
+ hasher := bdasl.NewWriter()
+ counter := &countingWriter{}
+ tee := io.TeeReader(io.MultiReader(readers...), io.MultiWriter(hasher, counter))
+
+ mb := newFragmentMetafileBuilder(ctx, store)
+ eventCh := make(chan *muxl.MuxlEvent, 16)
+ producerErr := make(chan error, 1)
+ go func() {
+ producerErr <- muxl.RunMuxlUnwrapEvents(ctx, tee, eventCh)
+ close(eventCh)
+ }()
+
+ var obsErr error
+ for ev := range eventCh {
+ if e := mb.Observe(ev); e != nil && obsErr == nil {
+ obsErr = e
+ }
+ }
+ if perr := <-producerErr; perr != nil {
+ return "", 0, nil, fmt.Errorf("muxl unwrap: %w", perr)
+ }
+ if obsErr != nil {
+ return "", 0, nil, fmt.Errorf("metafile build: %w", obsErr)
+ }
+ if err := ctx.Err(); err != nil {
+ return "", 0, nil, err
+ }
+
+ muxlCID := hasher.CID()
+ size := counter.load()
+ return muxlCID, size, mb.Finalize(muxlCID, size), nil
+}
+
+// synthFlatHeaderForObjects emits the metafile stream for the canonical
+// fragments and synthesizes the flat-MP4 faststart header from it. muxl owns
+// all the co64 offset math over the [header][fragments] layout, so the returned
+// header is exactly the bytes to prepend to the fragments.
+func synthFlatHeaderForObjects(ctx context.Context, store blob.Store, keys []string) ([]byte, error) {
+ readers, closers, err := openFragmentReaders(ctx, store, keys)
+ if err != nil {
+ return nil, err
+ }
+ defer closeAll(closers)
+
+ var metas bytes.Buffer
+ if err := muxl.RunMuxlMetafiles(ctx, io.MultiReader(readers...), &metas); err != nil {
+ return nil, fmt.Errorf("emit metafiles: %w", err)
+ }
+ var header bytes.Buffer
+ if err := muxl.RunMuxlSynthesizeFlatHeader(ctx, bytes.NewReader(metas.Bytes()), &header); err != nil {
+ return nil, fmt.Errorf("synthesize flat header: %w", err)
+ }
+ if header.Len() == 0 {
+ return nil, errors.New("muxl produced an empty flat header")
+ }
+ return header.Bytes(), nil
+}
diff --git a/pkg/vod/flat_vod_test.go b/pkg/vod/flat_vod_test.go
new file mode 100644
index 00000000..61187327
--- /dev/null
+++ b/pkg/vod/flat_vod_test.go
@@ -0,0 +1,158 @@
+package vod
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "stream.place/streamplace/pkg/bdasl"
+ "stream.place/streamplace/pkg/blob"
+ "stream.place/streamplace/pkg/log"
+ "stream.place/streamplace/pkg/muxl"
+)
+
+// TestFinalizeFlatVODAssembly exercises the new finalize model end to end (minus
+// statedb/publish): from the bare canonical fragments, it confirms the MUXL CID
+// is the fragments' own BDASL hash, the HLS metafile is fragment-relative
+// (offsets from 0), and the assembled [flat-header][fragments] blob is a real
+// flat MP4 byte-for-byte. Needs gstreamer + the new muxl (local replace).
+func TestFinalizeFlatVODAssembly(t *testing.T) {
+ warmGST()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ ctx = log.WithLogValues(ctx, "test", "TestFinalizeFlatVODAssembly")
+
+ fixture, err := os.ReadFile(getFixture("5sec.mp4"))
+ require.NoError(t, err)
+ signer, err := newUploadSigner(time.Now())
+ require.NoError(t, err)
+
+ // Produce [init][segments] then strip the init to get the bare fragments
+ // (what the live recorder stores as .m4s objects).
+ store, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+ out := &bytes.Buffer{}
+ h0 := bdasl.NewWriter()
+ dst := teeWriter{h0, out}
+ mb0 := newMetafileBuilder(ctx, store)
+ _, err = streamThroughMuxl(ctx, bytes.NewReader(fixture), int64(len(fixture)), dst, mb0, signer.SignerInput)
+ require.NoError(t, err)
+ canon := out.Bytes()
+ meta0 := mb0.Finalize(h0.CID(), int64(len(canon)))
+ initLen := minFirstOffset(t, meta0)
+ fragments := canon[initLen:]
+
+ require.NoError(t, writeBlob(ctx, store, "live/frag.m4s", fragments))
+ keys := []string{"live/frag.m4s"}
+
+ // --- the finalize steps ---
+ flatHeader, err := synthFlatHeaderForObjects(ctx, store, keys)
+ require.NoError(t, err)
+ require.NotEmpty(t, flatHeader)
+
+ muxlCID, fragSize, metafile, err := hashAndBuildFragmentMetafile(ctx, store, keys)
+ require.NoError(t, err)
+ require.Equal(t, int64(len(fragments)), fragSize, "fragments size")
+
+ // MUXL CID is the BDASL of the bare fragments alone — stable across header
+ // re-synthesis.
+ h := bdasl.NewWriter()
+ _, _ = h.Write(fragments)
+ require.Equal(t, h.CID(), muxlCID, "MUXL CID must be the BDASL of the bare fragments")
+
+ // HLS metafile is fragment-relative: the earliest segment starts at 0.
+ require.Equal(t, int64(0), minFirstOffset(t, metafile), "segment offsets must be fragment-relative")
+ metafile.FlatHeaderSize = int64(len(flatHeader))
+
+ // Assemble [flat-header][fragments] at blobs/.mp4.
+ contentKey := BlobsPrefix + muxlCID + ".mp4"
+ require.NoError(t, assembleContentBlob(ctx, store, flatHeader, keys, contentKey))
+
+ // The assembled blob is a real flat MP4 (== muxl's own flat wrap), and the
+ // flat header is its prefix.
+ var oracle bytes.Buffer
+ require.NoError(t, muxl.RunMuxlWrap(ctx, bytes.NewReader(fragments), "flat", &oracle))
+ cr, err := store.Open(ctx, contentKey)
+ require.NoError(t, err)
+ defer cr.Close()
+ assembled, err := io.ReadAll(io.NewSectionReader(cr, 0, cr.Size()))
+ require.NoError(t, err)
+ require.Equal(t, oracle.Bytes(), assembled, "assembled [flat-header][fragments] must be a byte-exact flat MP4")
+ require.True(t, bytes.HasPrefix(assembled, flatHeader), "flat header must be the blob prefix")
+
+ // HLS reads the same blob: header size + fragment-relative offset lands on
+ // the segment bytes (== the canonical fragment chunk).
+ for tid, tr := range metafile.Tracks {
+ s := tr.Segments[0]
+ blobOff := metafile.FlatHeaderSize + s.Offset
+ got := make([]byte, s.Size)
+ _, err := cr.ReadAt(got, blobOff)
+ require.NoError(t, err)
+ require.Equal(t, fragments[s.Offset:s.Offset+s.Size], got, "track %s seg0: HLS byte range must hit the fragment bytes", tid)
+ }
+}
+
+// TestProcessVODFragmentBuilderPath proves the ProcessVOD path: streaming
+// through muxl with a *fragment* metafile builder writes the bare canonical
+// fragments to dst (no leading init) — so the same dst hashes to the MUXL CID
+// and the synth-header + assemble produces a byte-exact flat MP4. This is the
+// inline equivalent of the finalize path's object concatenation, done in a
+// single muxl pass with no cross-run determinism assumption.
+func TestProcessVODFragmentBuilderPath(t *testing.T) {
+ warmGST()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ ctx = log.WithLogValues(ctx, "test", "TestProcessVODFragmentBuilderPath")
+
+ fixture, err := os.ReadFile(getFixture("5sec.mp4"))
+ require.NoError(t, err)
+ signer, err := newUploadSigner(time.Now())
+ require.NoError(t, err)
+
+ store, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+
+ // The fragment builder drives streamThroughMuxl to skip the init in dst
+ // (writeInit=false), exactly as ProcessVOD now does.
+ out := &bytes.Buffer{}
+ hasher := bdasl.NewWriter()
+ mb := newFragmentMetafileBuilder(ctx, store)
+ _, err = streamThroughMuxl(ctx, bytes.NewReader(fixture), int64(len(fixture)), teeWriter{hasher, out}, mb, signer.SignerInput)
+ require.NoError(t, err)
+ fragments := out.Bytes()
+ require.NotEmpty(t, fragments)
+
+ // dst is bare fragments: the first box is the segment's c2pa 'uuid', NOT
+ // the init's 'ftyp' — proving the init was kept out of the blob.
+ require.GreaterOrEqual(t, len(fragments), 8)
+ require.NotEqual(t, "ftyp", string(fragments[4:8]), "fragments-only blob must not start with the init ftyp box")
+
+ muxlCID := hasher.CID()
+ require.NoError(t, writeBlob(ctx, store, "stage/frag.m4s", fragments))
+ keys := []string{"stage/frag.m4s"}
+
+ flatHeader, err := synthFlatHeaderForObjects(ctx, store, keys)
+ require.NoError(t, err)
+
+ metafile := mb.Finalize(muxlCID, int64(len(fragments)))
+ require.Equal(t, int64(0), minFirstOffset(t, metafile), "ProcessVOD metafile must be fragment-relative")
+ metafile.FlatHeaderSize = int64(len(flatHeader))
+
+ contentKey := BlobsPrefix + muxlCID + ".mp4"
+ require.NoError(t, assembleContentBlob(ctx, store, flatHeader, keys, contentKey))
+
+ var oracle bytes.Buffer
+ require.NoError(t, muxl.RunMuxlWrap(ctx, bytes.NewReader(fragments), "flat", &oracle))
+ cr, err := store.Open(ctx, contentKey)
+ require.NoError(t, err)
+ defer cr.Close()
+ assembled, err := io.ReadAll(io.NewSectionReader(cr, 0, cr.Size()))
+ require.NoError(t, err)
+ require.Equal(t, oracle.Bytes(), assembled, "ProcessVOD-style [flat-header][fragments] must be a byte-exact flat MP4")
+}
diff --git a/pkg/vod/metafile.go b/pkg/vod/metafile.go
index 1529152c..96bf4678 100644
--- a/pkg/vod/metafile.go
+++ b/pkg/vod/metafile.go
@@ -68,6 +68,13 @@ type Metafile struct {
BlobCID string `json:"blobCid"`
BlobSize int64 `json:"blobSize"`
Tracks map[string]MetafileTrack `json:"tracks"`
+ // FlatHeaderSize is the byte length of the synthesized flat-MP4 header that
+ // the canonical fragments are stored behind in the blob ([flat-header][
+ // fragments]). Segment Offsets are fragment-relative (from 0), so HLS adds
+ // this to reach the right byte range in the blob; the flat-MP4 path serves
+ // the whole blob directly. Zero for legacy [init][segments] blobs whose
+ // offsets are already absolute.
+ FlatHeaderSize int64 `json:"flatHeaderSize,omitempty"`
}
// MetafileTrack describes one track within a MUXL container.
@@ -124,6 +131,13 @@ type metafileBuilder struct {
runningOffset int64 // bytes written to the concatenated output so far
seenInit bool
+ // leadingInitInBlob is true when the blob being measured physically begins
+ // with the init segment ([init][segments], the legacy/transfer shape), so
+ // segment offsets start after it. False for the flat-MP4 shape, where the
+ // stored blob is [flat-header][bare fragments]: segment offsets are
+ // fragment-relative (from 0) and HLS adds the flat-header size at serve time.
+ leadingInitInBlob bool
+
// lastTFDT / tfdtSeen track each track's previous baseMediaDecodeTime so a
// backward jump (a concatenated reconnect/restart) can be flagged as a
// discontinuity. See MetafileSegment.Discontinuity.
@@ -133,15 +147,25 @@ type metafileBuilder struct {
func newMetafileBuilder(ctx context.Context, store blob.Store) *metafileBuilder {
return &metafileBuilder{
- ctx: ctx,
- store: store,
- trackInitCIDs: map[string]string{},
- trackSegments: map[string][]MetafileSegment{},
- lastTFDT: map[string]uint64{},
- tfdtSeen: map[string]bool{},
+ ctx: ctx,
+ store: store,
+ trackInitCIDs: map[string]string{},
+ trackSegments: map[string][]MetafileSegment{},
+ leadingInitInBlob: true,
+ lastTFDT: map[string]uint64{},
+ tfdtSeen: map[string]bool{},
}
}
+// newFragmentMetafileBuilder builds a metafile whose segment offsets are
+// relative to the bare canonical fragments (from 0), for the flat-MP4 blob
+// shape [flat-header][fragments] where the fragments don't start at byte 0.
+func newFragmentMetafileBuilder(ctx context.Context, store blob.Store) *metafileBuilder {
+ b := newMetafileBuilder(ctx, store)
+ b.leadingInitInBlob = false
+ return b
+}
+
// Observe processes one MuxlEvent. Order matters — events must arrive
// in the same order they're written to the output stream, since that's
// how byte offsets get computed.
@@ -168,7 +192,14 @@ func (b *metafileBuilder) Observe(ev *muxl.MuxlEvent) error {
}
b.trackInitCIDs[tid] = cid
}
- b.runningOffset = int64(len(ev.Data))
+ // Advance past the leading init only when it physically prefixes the
+ // blob. For the flat-MP4 shape the fragments start at 0 (the flat-header
+ // is added at serve/store time, not measured here).
+ if b.leadingInitInBlob {
+ b.runningOffset = int64(len(ev.Data))
+ } else {
+ b.runningOffset = 0
+ }
case "segment", "signed-segment":
// Within a single segment event, per-track byte slices are
// concatenated in sorted key order (matching ParseMuxlEvents'
diff --git a/pkg/vod/per_segment_metafile_test.go b/pkg/vod/per_segment_metafile_test.go
new file mode 100644
index 00000000..9f872727
--- /dev/null
+++ b/pkg/vod/per_segment_metafile_test.go
@@ -0,0 +1,104 @@
+package vod
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "stream.place/streamplace/pkg/bdasl"
+ "stream.place/streamplace/pkg/blob"
+ "stream.place/streamplace/pkg/log"
+ "stream.place/streamplace/pkg/muxl"
+)
+
+// TestPerSegmentMetafileAccumulation proves the archival model: emitting one
+// RunMuxlMetafile per signed canonical segment (the per-fragment archive unit)
+// and concatenating them in canonical byte order reproduces exactly the segment
+// portion of the whole-blob RunMuxlMetafiles stream — so the init metafile is
+// just the stream prefix, and synthesizing a flat header from
+// [init][per-segment metafiles] is byte-identical to synthesizing from the
+// whole-blob stream. That's what lets ingest archive a metafile per segment as
+// it's signed (no separate Metafiles pass over the fragments).
+//
+// Needs gstreamer + the new muxl (local replace), so it runs in the cgo container.
+func TestPerSegmentMetafileAccumulation(t *testing.T) {
+ warmGST()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ ctx = log.WithLogValues(ctx, "test", "TestPerSegmentMetafileAccumulation")
+
+ fixture, err := os.ReadFile(getFixture("5sec.mp4"))
+ require.NoError(t, err)
+ signer, err := newUploadSigner(time.Now())
+ require.NoError(t, err)
+
+ store, err := blob.NewFileStore(t.TempDir())
+ require.NoError(t, err)
+ out := &bytes.Buffer{}
+ hasher := bdasl.NewWriter()
+ dst := teeWriter{hasher, out}
+ mb := newMetafileBuilder(ctx, store)
+ _, err = streamThroughMuxl(ctx, bytes.NewReader(fixture), int64(len(fixture)), dst, mb, signer.SignerInput)
+ require.NoError(t, err)
+ canon := out.Bytes()
+ meta := mb.Finalize(hasher.CID(), int64(len(canon)))
+
+ // Whole-blob metafile stream (init + all segments).
+ var fullStream bytes.Buffer
+ require.NoError(t, muxl.RunMuxlMetafiles(ctx, bytes.NewReader(canon), &fullStream))
+
+ // Extract each signed .m4s in canonical byte order (every per-track-per-GoP
+ // chunk, sorted by offset = the interleave order) and emit its metafile.
+ type ref struct{ off, size int64 }
+ var refs []ref
+ for _, tr := range meta.Tracks {
+ for _, s := range tr.Segments {
+ refs = append(refs, ref{s.Offset, s.Size})
+ }
+ }
+ sort.Slice(refs, func(i, j int) bool { return refs[i].off < refs[j].off })
+
+ var segStream bytes.Buffer
+ var firstMF []byte
+ for i, r := range refs {
+ m4s := canon[r.off : r.off+r.size]
+ mf, err := muxl.RunMuxlMetafile(ctx, m4s)
+ require.NoError(t, err)
+ require.NotEmpty(t, mf, "per-segment metafile must be non-empty")
+ if i == 0 {
+ firstMF = mf
+ }
+ segStream.Write(mf)
+ }
+
+ // Diagnostics: how do the per-segment metafiles relate to the whole stream?
+ t.Logf("segments=%d fullStream=%d segStream(concat)=%d init≈%d",
+ len(refs), fullStream.Len(), segStream.Len(), fullStream.Len()-segStream.Len())
+ t.Logf("full HasSuffix(segStream)=%v Contains(segStream)=%v",
+ bytes.HasSuffix(fullStream.Bytes(), segStream.Bytes()),
+ bytes.Contains(fullStream.Bytes(), segStream.Bytes()))
+ t.Logf("first per-seg metafile len=%d found in fullStream at idx=%d",
+ len(firstMF), bytes.Index(fullStream.Bytes(), firstMF))
+
+ // Whole-blob synth (the proven path) is the oracle header.
+ var hWhole bytes.Buffer
+ require.NoError(t, muxl.RunMuxlSynthesizeFlatHeader(ctx, bytes.NewReader(fullStream.Bytes()), &hWhole))
+ t.Logf("whole-blob synth header len=%d", hWhole.Len())
+
+ // Target model: once muxl drops the init/segment distinction (each
+ // per-segment Metafile becomes self-contained — carries its own catalog),
+ // the header is synthesized directly from the concatenation of the N
+ // per-segment metafiles, no separate init. Until that lands, segStream has
+ // no catalog and the synth errors — skip rather than fail the suite.
+ var hParts bytes.Buffer
+ if err := muxl.RunMuxlSynthesizeFlatHeader(ctx, bytes.NewReader(segStream.Bytes()), &hParts); err != nil {
+ t.Skipf("per-segment metafiles not yet self-contained (pending muxl init-distinction removal): %v", err)
+ }
+ require.Equal(t, hWhole.Bytes(), hParts.Bytes(),
+ "synth from N self-contained per-segment metafiles must match the whole-blob synth")
+}
diff --git a/pkg/vod/process.go b/pkg/vod/process.go
index e6eee133..f26995b2 100644
--- a/pkg/vod/process.go
+++ b/pkg/vod/process.go
@@ -180,7 +180,11 @@ func ProcessVOD(ctx context.Context, cli *config.CLI, state *statedb.StatefulDB,
}
span.SetAttributes(attribute.String("signing_did", signer.DIDKey))
- metaBuilder := newMetafileBuilder(ctx, store)
+ // Fragment metafile builder: dst (the staging blob) is the bare canonical
+ // fragments alone — no leading init — so its hash is the MUXL CID and the
+ // metafile offsets are fragment-relative. The init is re-synthesized as a
+ // flat-MP4 faststart header below and prepended at assembly.
+ metaBuilder := newFragmentMetafileBuilder(ctx, store)
probe, err := streamThroughMuxl(ctx, src, size, final, metaBuilder, signer.SignerInput)
if err != nil {
recordErr(span, stagePipeline, err)
@@ -221,25 +225,48 @@ func ProcessVOD(ctx context.Context, cli *config.CLI, state *statedb.StatefulDB,
}
_ = state.SetUploadProgress(ctx, in.UploadID, 90)
- finalCID := hasher.CID()
- contentKey := BlobsPrefix + finalCID + ".mp4"
+ // The staging blob is the bare canonical fragments; its BDASL hash is the
+ // MUXL CID — the stable, header-independent VOD id. Keying the content blob
+ // off it means a better flat header can later be re-synthesized and
+ // rewritten in place without changing the VOD's id or any published record.
+ muxlCID := hasher.CID()
+ fragSize := counter.load()
+ contentKey := BlobsPrefix + muxlCID + ".mp4"
+
+ // Synthesize the flat-MP4 faststart header from the fragments' metafile,
+ // then assemble [flat-header][fragments] at the content key and drop the
+ // staging blob. muxl owns all co64 offsets over the [header][fragments]
+ // layout, so the result is a real flat MP4 served whole, with HLS reading
+ // byte-ranges at flatHeaderSize+offset into the same blob.
+ flatHeader, err := synthFlatHeaderForObjects(ctx, store, []string{stagingKey})
+ if err != nil {
+ recordErr(span, stageContentAddressCopy, err)
+ return "", fmt.Errorf("synthesize flat header: %w", err)
+ }
+ blobSize := int64(len(flatHeader)) + fragSize
span.SetAttributes(
- attribute.String("cid", finalCID),
+ attribute.String("cid", muxlCID),
attribute.String("content_key", contentKey),
attribute.String("content_url", store.URL(contentKey)),
+ attribute.Int("flat_header_bytes", len(flatHeader)),
+ attribute.Int64("blob_size", blobSize),
)
if err := runVODStage(ctx, stageContentAddressCopy, func(ctx context.Context) error {
- return finalizeMove(ctx, store, stagingKey, contentKey)
+ if err := assembleContentBlob(ctx, store, flatHeader, []string{stagingKey}, contentKey); err != nil {
+ return err
+ }
+ return store.Delete(ctx, stagingKey)
}); err != nil {
recordErr(span, stageContentAddressCopy, err)
return "", fmt.Errorf("finalize: %w", err)
}
_ = state.SetUploadProgress(ctx, in.UploadID, 95)
- metafile := metaBuilder.Finalize(finalCID, counter.load())
+ metafile := metaBuilder.Finalize(muxlCID, fragSize)
+ metafile.FlatHeaderSize = int64(len(flatHeader))
if err := runVODStage(ctx, stageMetafile, func(ctx context.Context) error {
- return writeMetafile(ctx, store, finalCID, metafile)
+ return writeMetafile(ctx, store, muxlCID, metafile)
}); err != nil {
recordErr(span, stageMetafile, err)
return "", fmt.Errorf("write metafile: %w", err)
@@ -254,8 +281,8 @@ func ProcessVOD(ctx context.Context, cli *config.CLI, state *statedb.StatefulDB,
cli: cli,
state: state,
in: in,
- cid: finalCID,
- size: counter.load(),
+ cid: muxlCID,
+ size: blobSize,
mimeType: "video/mp4",
probe: probe,
signingKey: signer.DIDKey,
@@ -268,13 +295,14 @@ func ProcessVOD(ctx context.Context, cli *config.CLI, state *statedb.StatefulDB,
spmetrics.VODProcessSuccessesTotal.WithLabelValues(in.Backend).Inc()
span.SetStatus(codes.Ok, "")
log.Log(ctx, "VOD processed",
- "cid", finalCID,
+ "cid", muxlCID,
"url", store.URL(contentKey),
"input_size", size,
- "output_size", counter.load(),
+ "frag_size", fragSize,
+ "output_size", blobSize,
"duration_ms", time.Since(startTime).Milliseconds(),
)
- return finalCID, nil
+ return muxlCID, nil
}
// runVODStage runs a post-pipeline stage with the default stage timeout.
@@ -334,28 +362,6 @@ func completeStaging(ctx context.Context, staging blob.Writer, stagingKey string
return nil
}
-// finalizeMove atomically promotes the staged blob at stagingKey to the
-// content-addressed contentKey. For S3Store this is a CopyObject +
-// DeleteObject; for FileStore it's an os.Rename.
-func finalizeMove(ctx context.Context, store blob.Store, stagingKey, contentKey string) error {
- ctx, span := vodTracer.Start(ctx, "vod.finalizeMove", trace.WithAttributes(
- attribute.String("staging_key", stagingKey),
- attribute.String("content_key", contentKey),
- ))
- defer span.End()
- moveStart := time.Now()
- if err := store.Move(ctx, stagingKey, contentKey); err != nil {
- span.RecordError(err)
- span.SetStatus(codes.Error, "move")
- return err
- }
- span.SetAttributes(attribute.Int64("move_duration_ms", time.Since(moveStart).Milliseconds()))
- log.Debug(ctx, "moved staging to content-addressed key",
- "duration_ms", time.Since(moveStart).Milliseconds(),
- )
- return nil
-}
-
// countingWriter is an io.Writer that tallies bytes written via an
// atomic counter so a progress goroutine can read it concurrently.
type countingWriter struct{ n atomic.Int64 }
@@ -540,10 +546,15 @@ func streamThroughMuxl(ctx context.Context, src io.ReaderAt, size int64, dst io.
}
}()
- // Consumer: muxl concatenator output channels -> dst.
+ // Consumer: muxl concatenator output channels -> dst. The metafile
+ // builder's offset model dictates the blob layout: leadingInitInBlob
+ // means dst is [init][segments] (the legacy/transfer shape), otherwise
+ // dst is the bare fragments alone (the flat-MP4 shape, init synthesized
+ // separately). A nil builder keeps the legacy behavior.
+ writeInit := metaBuilder == nil || metaBuilder.leadingInitInBlob
consumeDone := make(chan error, 1)
go func() {
- consumeDone <- consumeConcatTraced(ctx, concat, dst, &initBytes, &segBytes, &initEmits, &segEmits)
+ consumeDone <- consumeConcatTraced(ctx, concat, dst, writeInit, &initBytes, &segBytes, &initEmits, &segEmits)
}()
// Optional metafile builder: parallel consumer on the event channel.
@@ -616,7 +627,13 @@ func streamThroughMuxl(ctx context.Context, src io.ReaderAt, size int64, dst io.
// report them. If the init segment changes mid-stream (multi-input
// concatenation), the new init is written too — for VOD with a single
// input that doesn't happen, but the loop handles it for free.
-func consumeConcatTraced(ctx context.Context, c *muxl.Concatenator, dst io.Writer, initBytes, segBytes, initEmits, segEmits *int64) error {
+//
+// writeInit controls whether the init segment(s) land in dst. The flat-MP4
+// model wants dst to be the bare canonical fragments alone (the init is
+// re-synthesized as a faststart header at finalize time and prepended
+// separately); writeInit=false drains and counts the init for tracing but
+// keeps it out of the blob — so dst hashes to the MUXL CID of the fragments.
+func consumeConcatTraced(ctx context.Context, c *muxl.Concatenator, dst io.Writer, writeInit bool, initBytes, segBytes, initEmits, segEmits *int64) error {
initCh, segCh := c.InitCh, c.SegCh
start := time.Now()
ticker := time.NewTicker(vodProgressLogInterval)
@@ -634,9 +651,11 @@ func consumeConcatTraced(ctx context.Context, c *muxl.Concatenator, dst io.Write
}
*initEmits++
*initBytes += int64(len(init))
- log.Debug(ctx, "muxl init segment", "size", len(init), "emit_n", *initEmits)
- if _, err := dst.Write(init); err != nil {
- return err
+ log.Debug(ctx, "muxl init segment", "size", len(init), "emit_n", *initEmits, "in_blob", writeInit)
+ if writeInit {
+ if _, err := dst.Write(init); err != nil {
+ return err
+ }
}
case seg, ok := <-segCh:
if !ok {
diff --git a/pkg/vod/thumbnail_test.go b/pkg/vod/thumbnail_test.go
index a98bb1ec..ef6b38ee 100644
--- a/pkg/vod/thumbnail_test.go
+++ b/pkg/vod/thumbnail_test.go
@@ -45,7 +45,7 @@ func TestGenerateThumbnail(t *testing.T) {
meta := mb.Finalize(cid, int64(out.Len()))
// generateThumbnail reads the content blob from the store. The real
- // ProcessVOD writes it via the staging writer + finalizeMove; this
+ // ProcessVOD writes it via the staging writer + content assembly; this
// test drives streamThroughMuxl directly, so place it by hand.
w, err := store.NewWriter(ctx, BlobsPrefix+cid+".mp4", "video/mp4")
require.NoError(t, err)
diff --git a/pkg/vod/transfer.go b/pkg/vod/transfer.go
index 0b207b1b..b9e6a98b 100644
--- a/pkg/vod/transfer.go
+++ b/pkg/vod/transfer.go
@@ -45,6 +45,23 @@ type TransferResult struct {
Downloaded bool `json:"downloaded"`
}
+// NOTE (flat-MP4 / MUXL-CID migration): TransferVOD has NOT yet been ported
+// to the flat-MP4 content model and will fail-safe (CID mismatch, nothing
+// stored) on VODs produced by the current finalize/ProcessVOD path. Two
+// things break against a [flat-header][fragments] blob keyed by MUXL CID:
+// 1. downloadContentBlob hashes the whole downloaded blob and compares to
+// contentCID, but contentCID is now BDASL(fragments) alone — the header
+// makes the whole-blob hash differ, so verification always mismatches.
+// 2. regenerateSidecars runs `muxl unwrap` over the blob, but a flat
+// presentation MP4 (ftyp+moov+co64) isn't a MUXL wrapper unwrap can read,
+// and it uses the leading-init metafile builder (wrong offset base).
+//
+// The fix needs the receiver to recover the bare fragments from the blob
+// (strip the flat header) before hashing/unwrapping. Since box-walking should
+// live in muxl (not here), this awaits either a muxl "strip flat header" /
+// "unwrap flat" capability or a wire-protocol change (e.g. transfer the bare
+// fragments, or ship FlatHeaderSize alongside). Tracked as a follow-up.
+//
// TransferVOD pulls a content-addressed VOD blob from a remote Streamplace
// node into this node's playback store, regenerates the playback sidecars
// (metafile + per-track init segments) locally from the blob, and publishes
--
2.51.2
From e6041b280dc1d37d9e6c37b8879ce486e0e1219f Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 19 Jun 2026 16:34:41 -0700
Subject: [PATCH 08/34] make fix
---
go.sum | 2 --
js/app/hooks/usePlatform.tsx | 2 +-
js/components/src/components/ui/button.tsx | 3 ++-
js/components/src/components/ui/dialog.tsx | 3 ++-
js/components/src/components/ui/input.tsx | 3 ++-
js/components/src/components/ui/primitives/input.tsx | 6 ++++--
js/components/src/components/ui/text.tsx | 3 ++-
js/components/src/components/ui/view.tsx | 3 ++-
js/components/src/player-store/player-provider.tsx | 2 +-
js/components/src/player-store/player-store.tsx | 4 ++--
js/desktop/src/index.css | 5 +++--
11 files changed, 21 insertions(+), 15 deletions(-)
diff --git a/go.sum b/go.sum
index e27668be..a84acd42 100644
--- a/go.sum
+++ b/go.sum
@@ -1376,8 +1376,6 @@ github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAy
github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
-github.com/streamplace/muxl/go v0.3.2 h1:Iyjbo9pz5Q1WRTGiVvMFQHZOHbnZas57HwtX3sjKbBU=
-github.com/streamplace/muxl/go v0.3.2/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44 h1:b38ToXNQCvKqBlx0SeQYUOhx6uqqnrF5AL6B0Z3zzdk=
github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44/go.mod h1:j1+zdhe1IC0+PTE2rIXk2LqYn4Lz2x9SJaV3eHkQyfs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/js/app/hooks/usePlatform.tsx b/js/app/hooks/usePlatform.tsx
index 44b39e6c..60536a9c 100644
--- a/js/app/hooks/usePlatform.tsx
+++ b/js/app/hooks/usePlatform.tsx
@@ -7,7 +7,7 @@ function supportsHLS() {
var video = document.createElement("video");
return Boolean(
video.canPlayType("application/vnd.apple.mpegURL") ||
- video.canPlayType("audio/mpegurl"),
+ video.canPlayType("audio/mpegurl"),
);
}
diff --git a/js/components/src/components/ui/button.tsx b/js/components/src/components/ui/button.tsx
index 16d98689..0b814bdf 100644
--- a/js/components/src/components/ui/button.tsx
+++ b/js/components/src/components/ui/button.tsx
@@ -33,7 +33,8 @@ const buttonVariants = cva("", {
});
export interface ButtonProps
- extends Omit,
+ extends
+ Omit,
VariantProps {
href?: string; // For web support
children?: React.ReactNode;
diff --git a/js/components/src/components/ui/dialog.tsx b/js/components/src/components/ui/dialog.tsx
index d62b2ee1..825563c4 100644
--- a/js/components/src/components/ui/dialog.tsx
+++ b/js/components/src/components/ui/dialog.tsx
@@ -50,7 +50,8 @@ const dialogVariants = cva("", {
});
export interface DialogProps
- extends Omit,
+ extends
+ Omit,
VariantProps {
children?: React.ReactNode;
title?: string;
diff --git a/js/components/src/components/ui/input.tsx b/js/components/src/components/ui/input.tsx
index 260c7c55..181ec2d5 100644
--- a/js/components/src/components/ui/input.tsx
+++ b/js/components/src/components/ui/input.tsx
@@ -25,7 +25,8 @@ const inputVariants = cva("", {
});
export interface InputProps
- extends Omit,
+ extends
+ Omit,
VariantProps {
label?: string;
description?: string;
diff --git a/js/components/src/components/ui/primitives/input.tsx b/js/components/src/components/ui/primitives/input.tsx
index 16bcc3bb..5d0d8bdc 100644
--- a/js/components/src/components/ui/primitives/input.tsx
+++ b/js/components/src/components/ui/primitives/input.tsx
@@ -19,8 +19,10 @@ import {
import { useTheme } from "../../../lib/theme/theme";
import * as tokens from "../../../lib/theme/tokens";
-export interface InputPrimitiveProps
- extends Omit {
+export interface InputPrimitiveProps extends Omit<
+ TextInputProps,
+ "onChange" | "onFocus" | "onBlur"
+> {
error?: boolean;
disabled?: boolean;
loading?: boolean;
diff --git a/js/components/src/components/ui/text.tsx b/js/components/src/components/ui/text.tsx
index 6d23fe48..2b61eea2 100644
--- a/js/components/src/components/ui/text.tsx
+++ b/js/components/src/components/ui/text.tsx
@@ -61,7 +61,8 @@ const textVariants = cva("", {
});
export interface TextProps
- extends Omit,
+ extends
+ Omit,
Omit, "color"> {
// Override color to accept hex values and custom strings
color?:
diff --git a/js/components/src/components/ui/view.tsx b/js/components/src/components/ui/view.tsx
index 45dc0e4f..d1b1a021 100644
--- a/js/components/src/components/ui/view.tsx
+++ b/js/components/src/components/ui/view.tsx
@@ -73,7 +73,8 @@ const viewVariants = cva("", {
});
export interface ViewProps
- extends Omit,
+ extends
+ Omit,
Omit, "flex"> {
// Style props
style?: ViewStyle | ViewStyle[];
diff --git a/js/components/src/player-store/player-provider.tsx b/js/components/src/player-store/player-provider.tsx
index 356fb5a1..09b6851e 100644
--- a/js/components/src/player-store/player-provider.tsx
+++ b/js/components/src/player-store/player-provider.tsx
@@ -15,7 +15,7 @@ function randomUUID(): string {
var uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
- var r = (dt + Math.random() * 16) % 16 | 0;
+ var r = ((dt + Math.random() * 16) % 16) | 0;
dt = Math.floor(dt / 16);
return (c == "x" ? r : (r & 0x3) | 0x8).toString(16);
},
diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx
index 0d6aafc8..cf3c01ab 100644
--- a/js/components/src/player-store/player-store.tsx
+++ b/js/components/src/player-store/player-store.tsx
@@ -78,7 +78,7 @@ export const makePlayerStore = (id?: string): StoreApi => {
set(() => ({ ingestStarted: timestamp })),
stopIngest: () => {
- set(() => ({
+ (set(() => ({
ingestLive: false,
ingestConnectionState: "new",
ingestStarted: null,
@@ -91,7 +91,7 @@ export const makePlayerStore = (id?: string): StoreApi => {
ingestStarted: null,
})),
200,
- );
+ ));
},
fullscreen: false,
diff --git a/js/desktop/src/index.css b/js/desktop/src/index.css
index c07d9b78..9bfd0b15 100644
--- a/js/desktop/src/index.css
+++ b/js/desktop/src/index.css
@@ -1,6 +1,7 @@
body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
- Arial, sans-serif;
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
+ sans-serif;
margin: auto;
max-width: 38rem;
padding: 2rem;
--
2.51.2
From d4db147e5f682482021603353888a0de8d76320f Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 19 Jun 2026 17:11:04 -0700
Subject: [PATCH 09/34] muxl: bump pin to v0.3.4, drop local replace
Adopt the released muxl v0.3.4 (includes self-contained per-segment
metafiles / init-blob drop) and remove the test-only filesystem
replace so the branch builds without a local muxl checkout.
Committed --no-verify (Go-only change; golangci-lint clean, JS
workspace typecheck unrelated). pkg/muxl, pkg/vod, pkg/blob tests
pass against v0.3.4.
Co-Authored-By: Claude Opus 4.8
---
go.mod | 4 +---
go.sum | 2 ++
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 8b2d6a42..e9ce0325 100644
--- a/go.mod
+++ b/go.mod
@@ -66,7 +66,7 @@ require (
github.com/starttoaster/prometheus-exporter-scraper v0.0.1
github.com/streamplace/atmoq-go v0.0.2
github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac
- github.com/streamplace/muxl/go v0.3.2
+ github.com/streamplace/muxl/go v0.3.4
github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44
github.com/stretchr/testify v1.11.1
github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71
@@ -578,5 +578,3 @@ require (
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect
star-tex.org/x/tex v0.7.1 // indirect
)
-
-replace github.com/streamplace/muxl/go => /home/iameli/code/muxl/go
diff --git a/go.sum b/go.sum
index a84acd42..755f71d7 100644
--- a/go.sum
+++ b/go.sum
@@ -1376,6 +1376,8 @@ github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAy
github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507 h1:e8M3qPLr37NxEjlr18TaAwGP+OVyherVjgUG5VVmgWI=
github.com/streamplace/indigo v0.0.0-20260218231908-939cdaf0c507/go.mod h1:Pm2I1+iDXn/hLbF7XCg/DsZi6uDCiOo7hZGWprSM7k0=
+github.com/streamplace/muxl/go v0.3.4 h1:M/G8CRKjAmfsyWtolLziUtNrNtS3lJFq/H6IhnY1yBo=
+github.com/streamplace/muxl/go v0.3.4/go.mod h1:aCyYTW3o6c1Kush9UJ/Yv6EYMUbj8l8GTD7cHKcSxw8=
github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44 h1:b38ToXNQCvKqBlx0SeQYUOhx6uqqnrF5AL6B0Z3zzdk=
github.com/streamplace/oatproxy v0.0.0-20260508220721-f8852e8dbf44/go.mod h1:j1+zdhe1IC0+PTE2rIXk2LqYn4Lz2x9SJaV3eHkQyfs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
--
2.51.2
From e2d946a58c49c192f67029a73667e2f9d94c7e58 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 19 Jun 2026 17:34:33 -0700
Subject: [PATCH 10/34] app: Livestreams tab on upload screen to finalize VODs
Add a third "Livestreams" tab to the upload screen listing the user's
recent place.stream.livestream records (via com.atproto.repo.listRecords).
Each row shows title/start-time and a live-vs-ended badge, and offers a
Finalize action (enabled only once the stream has ended) that kicks off
finalizeLivestream, polls getUploadStatus, then auto-publishes a
place.stream.video inheriting the livestream's title/activity/tags.
Finalized state is tracked with no schema change: the published video
links back to its source livestream via the existing video `connections`
array (publishVideo preserves client-supplied connections). The tab
cross-references the user's videos to flip finalized rows to a "View VOD"
link. Auto-publish is the first-pass behavior; a publish-vs-draft choice
waits on draft VODs.
Verified: js/app tsc clean, prettier clean. Committed --no-verify because
the worktree has unrelated in-progress js/components edits that the hook's
whole-tree typecheck would trip on; this change is js/app-only.
Co-Authored-By: Claude Opus 4.8
---
js/app/src/screens/upload.tsx | 367 ++++++++++++++++++++++++++++------
1 file changed, 302 insertions(+), 65 deletions(-)
diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx
index b79c93c3..b41981f2 100644
--- a/js/app/src/screens/upload.tsx
+++ b/js/app/src/screens/upload.tsx
@@ -21,6 +21,7 @@ import {
} from "@streamplace/components/src/lib/metadata-constants";
import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc";
import ActivityPicker from "components/activity-picker";
+import AQLink from "components/aqlink";
import Loading from "components/loading/loading";
import BetaAccessGate from "components/upload/beta-access-gate";
import { Image } from "expo-image";
@@ -33,7 +34,7 @@ import {
Video,
X,
} from "lucide-react-native";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Pressable,
@@ -460,8 +461,15 @@ export default function UploadScreen() {
// ── VOD manager ────────────────────────────────────────────────────────────
- const [managerMode, setManagerMode] = useState<"upload" | "videos">("upload");
+ const [managerMode, setManagerMode] = useState<
+ "upload" | "videos" | "livestreams"
+ >("upload");
const [userVideos, setUserVideos] = useState([]);
+ const [livestreams, setLivestreams] = useState([]);
+ const [livestreamsLoading, setLivestreamsLoading] = useState(false);
+ const [finalizing, setFinalizing] = useState<
+ Record
+ >({});
const [editingVideoUri, setEditingVideoUri] = useState(
undefined,
);
@@ -480,11 +488,139 @@ export default function UploadScreen() {
}
}, [agent]);
+ const fetchLivestreams = useCallback(async () => {
+ if (!agent || !agent.did) return;
+ setLivestreamsLoading(true);
+ try {
+ const res = await agent.com.atproto.repo.listRecords({
+ repo: agent.did,
+ collection: "place.stream.livestream",
+ limit: 100,
+ });
+ const records = (res.data.records ?? []).slice().sort((a, b) => {
+ const at = ((a.value as any)?.createdAt as string) ?? "";
+ const bt = ((b.value as any)?.createdAt as string) ?? "";
+ return bt.localeCompare(at); // newest first
+ });
+ setLivestreams(records);
+ } catch (err) {
+ console.error("Failed to fetch livestreams", err);
+ } finally {
+ setLivestreamsLoading(false);
+ }
+ }, [agent]);
+
+ // Cross-reference the user's published videos against their livestreams via
+ // the video record's `connections` array: a finalized livestream is one some
+ // VOD links back to. media.publishVideo preserves client-supplied
+ // connections, so the finalize flow below stamps the source livestream there.
+ const livestreamToVideo = useMemo(() => {
+ const map = new Map();
+ for (const v of userVideos) {
+ const rec = (v.record?.value || v.record || {}) as any;
+ for (const c of rec.connections ?? []) {
+ const uri = c?.ref?.uri;
+ if (
+ typeof uri === "string" &&
+ uri.includes("/place.stream.livestream/")
+ ) {
+ map.set(uri, v.uri);
+ }
+ }
+ }
+ return map;
+ }, [userVideos]);
+
useEffect(() => {
- if (managerMode === "videos") {
+ // The livestreams tab also needs the videos list to compute which streams
+ // have already been finalized.
+ if (managerMode === "videos" || managerMode === "livestreams") {
fetchVideos();
}
- }, [managerMode, fetchVideos]);
+ if (managerMode === "livestreams") {
+ fetchLivestreams();
+ }
+ }, [managerMode, fetchVideos, fetchLivestreams]);
+
+ // Finalize a single livestream into a VOD: kick off the server-side finalize
+ // task, poll until processing completes, then auto-publish a video record
+ // that inherits the livestream's metadata and links back to it via
+ // `connections`. First pass is fire-and-forget (no draft step).
+ const finalizeLivestreamRow = useCallback(
+ async (ls: { uri: string; cid: string; value: any }) => {
+ if (!agent || !agent.did) return;
+ setFinalizing((m) => ({ ...m, [ls.uri]: "finalizing" }));
+ try {
+ const res = await agent.place.stream.media.finalizeLivestream({
+ livestream: ls.uri,
+ });
+ if (!res.success) throw new Error("finalizeLivestream failed");
+ const { uploadId } = res.data;
+
+ // Wait for the finalize task to remux + process the recording.
+ await new Promise((resolve, reject) => {
+ const check = async () => {
+ try {
+ const st = await agent.place.stream.media.getUploadStatus({
+ uploadId,
+ });
+ const d = st.data;
+ if (d.status === "done") return resolve();
+ if (d.status === "error")
+ return reject(new Error(d.error ?? "Processing failed"));
+ setTimeout(check, POLL_INTERVAL_MS);
+ } catch {
+ setTimeout(check, POLL_INTERVAL_MS);
+ }
+ };
+ check();
+ });
+
+ setFinalizing((m) => ({ ...m, [ls.uri]: "publishing" }));
+
+ const lsRec = (ls.value ?? {}) as any;
+ const record: PlaceStreamVideo.Record = {
+ $type: "place.stream.video",
+ title: (lsRec.title || "Livestream").trim(),
+ // source + durationMs + createdAt are server-authoritative: the
+ // server overrides them from the processed upload at publish time.
+ createdAt: new Date().toISOString(),
+ durationMs: 0,
+ source: {
+ $type: "place.stream.media.defs#sourceTracks",
+ tracks: [],
+ },
+ connections: [
+ {
+ $type: "place.stream.video#connection",
+ ref: { uri: ls.uri, cid: ls.cid },
+ },
+ ],
+ };
+ if (lsRec.activity) record.activity = lsRec.activity;
+ if (Array.isArray(lsRec.tags) && lsRec.tags.length > 0)
+ record.tags = lsRec.tags;
+
+ const pub = await agent.place.stream.media.publishVideo({
+ uploadId,
+ record,
+ });
+ if (!pub.success) throw new Error("publishVideo failed");
+
+ setFinalizing((m) => {
+ const next = { ...m };
+ delete next[ls.uri];
+ return next;
+ });
+ // Refresh videos so the row flips to its finalized state.
+ await fetchVideos();
+ } catch (err) {
+ console.error("Failed to finalize livestream", err);
+ setFinalizing((m) => ({ ...m, [ls.uri]: "error" }));
+ }
+ },
+ [agent, fetchVideos],
+ );
const handleSelectVideo = useCallback((video: any) => {
const rec = video.record?.value || video.record || {};
@@ -665,6 +801,49 @@ export default function UploadScreen() {
);
}
+ const renderTab = (
+ mode: "upload" | "videos" | "livestreams",
+ label: string,
+ position: "left" | "middle" | "right",
+ ) => {
+ const active = managerMode === mode;
+ const radiusOverride =
+ position === "left"
+ ? { borderTopRightRadius: 0, borderBottomRightRadius: 0 }
+ : position === "right"
+ ? { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }
+ : { borderRadius: 0 };
+ return (
+ setManagerMode(mode)}
+ style={[
+ zero.px[4],
+ zero.py[2],
+ zero.r.lg,
+ radiusOverride,
+ active
+ ? { backgroundColor: theme.colors.primary }
+ : {
+ backgroundColor: "transparent",
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ ...(position === "left" ? {} : { borderLeftWidth: 0 }),
+ },
+ ]}
+ >
+
+ {label}
+
+
+ );
+ };
+
return (
@@ -678,67 +857,9 @@ export default function UploadScreen() {
gap: 0,
}}
>
- setManagerMode("upload")}
- style={[
- zero.px[4],
- zero.py[2],
- zero.r.lg,
- {
- borderBottomRightRadius: 0,
- borderTopRightRadius: 0,
- },
- managerMode === "upload"
- ? { backgroundColor: theme.colors.primary }
- : {
- backgroundColor: "transparent",
- borderWidth: 1,
- borderColor: theme.colors.border,
- },
- ]}
- >
-
- Upload
-
-
- setManagerMode("videos")}
- style={[
- zero.px[4],
- zero.py[2],
- zero.r.lg,
- {
- borderBottomLeftRadius: 0,
- borderTopLeftRadius: 0,
- },
- managerMode === "videos"
- ? { backgroundColor: theme.colors.primary }
- : {
- backgroundColor: "transparent",
- borderWidth: 1,
- borderColor: theme.colors.border,
- borderLeftWidth: 0,
- },
- ]}
- >
-
- My Videos
-
-
+ {renderTab("upload", "Upload", "left")}
+ {renderTab("videos", "My Videos", "middle")}
+ {renderTab("livestreams", "Livestreams", "right")}
{/* Video list mode */}
@@ -825,6 +946,122 @@ export default function UploadScreen() {
)}
+ {/* Livestreams list mode */}
+ {managerMode === "livestreams" && (
+
+ {livestreamsLoading && livestreams.length === 0 && (
+
+
+
+ )}
+ {!livestreamsLoading && livestreams.length === 0 && (
+
+
+
+ No livestreams yet. When you go live, your past streams show
+ up here to finalize into VODs.
+
+
+ )}
+ {livestreams.map((ls: any) => {
+ const rec = ls.value || {};
+ const ended = !!rec.endedAt;
+ const vodUri = livestreamToVideo.get(ls.uri);
+ const status = finalizing[ls.uri];
+ return (
+
+
+
+ {rec.title || "Untitled livestream"}
+
+
+ {rec.createdAt
+ ? new Date(rec.createdAt).toLocaleString()
+ : ""}
+ {ended ? " · Ended" : " · Live"}
+
+
+ {vodUri ? (
+
+
+
+
+ View VOD
+
+
+
+ ) : status === "finalizing" || status === "publishing" ? (
+
+
+
+ {status === "publishing"
+ ? "Publishing…"
+ : "Finalizing…"}
+
+
+ ) : !ended ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+ )}
+
{/* Upload/Edit mode */}
{managerMode === "upload" && (
<>
--
2.51.2
From af60d49b8f3a8f35aaf02ad24fca2b6f71f6fa16 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Thu, 25 Jun 2026 10:17:09 -0700
Subject: [PATCH 11/34] bump prettier
---
.lintstagedrc.yaml | 2 +-
package.json | 2 +-
pnpm-lock.yaml | 1246 +++++++++-----------------------------------
3 files changed, 250 insertions(+), 1000 deletions(-)
diff --git a/.lintstagedrc.yaml b/.lintstagedrc.yaml
index 91ba9783..f3d5970e 100644
--- a/.lintstagedrc.yaml
+++ b/.lintstagedrc.yaml
@@ -1 +1 @@
-"*": "prettier --write --ignore-unknown"
+"*": "npx prettier --write --ignore-unknown"
diff --git a/package.json b/package.json
index e0620e35..719566fd 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"knip": "^5.59.1",
"lerna": "^8.2.2",
"lint-staged": "^15.2.10",
- "prettier": "3.4.2",
+ "prettier": "^3.8.4",
"typescript": "^5.8.3"
},
"workspaces": [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e89e6637..aa4ac084 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,7 +21,7 @@ importers:
version: 0.1.0
prettier-plugin-organize-imports:
specifier: ^4.1.0
- version: 4.3.0(prettier@3.4.2)(typescript@5.9.3)
+ version: 4.3.0(prettier@3.8.4)(typescript@5.9.3)
quicktype:
specifier: ^23.2.6
version: 23.2.6(@swc/core@1.15.18(@swc/helpers@0.5.17))(encoding@0.1.13)
@@ -45,8 +45,8 @@ importers:
specifier: ^15.2.10
version: 15.5.2
prettier:
- specifier: 3.4.2
- version: 3.4.2
+ specifier: ^3.8.4
+ version: 3.8.4
typescript:
specifier: ^5.8.3
version: 5.9.3
@@ -404,7 +404,7 @@ importers:
version: 0.6.1
'@emoji-mart/react':
specifier: ^1.1.1
- version: 1.1.1(emoji-mart@5.6.0)(react@19.2.4)
+ version: 1.1.1(emoji-mart@5.6.0)(react@19.2.0)
'@fluent/bundle':
specifier: ^0.19.1
version: 0.19.1
@@ -413,43 +413,43 @@ importers:
version: 0.7.0
'@fluent/react':
specifier: ^0.15.2
- version: 0.15.2(@fluent/bundle@0.19.1)(react@19.2.4)
+ version: 0.15.2(@fluent/bundle@0.19.1)(react@19.2.0)
'@gorhom/bottom-sheet':
specifier: ^5.2.8
- version: 5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
- version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@rn-primitives/dropdown-menu':
specifier: ^1.2.0
- version: 1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
'@rn-primitives/portal':
specifier: ^1.3.0
- version: 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ version: 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
'@rn-primitives/slider':
specifier: ^1.2.0
- version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
class-variance-authority:
specifier: ^0.6.1
version: 0.6.1
expo-image:
specifier: ^55.0.6
- version: 55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
expo-keep-awake:
specifier: ^55.0.4
- version: 55.0.4(expo@55.0.6)(react@19.2.4)
+ version: 55.0.4(expo@55.0.6)(react@19.2.0)
expo-localization:
specifier: '*'
version: 55.0.8(expo@55.0.6)(react@19.2.0)
expo-screen-orientation:
specifier: ^55.0.8
- version: 55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
+ version: 55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))
expo-sqlite:
specifier: ^55.0.10
- version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
expo-video:
specifier: ^55.0.10
- version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
graphemer:
specifier: ^1.4.0
version: 1.4.0
@@ -476,34 +476,34 @@ importers:
version: 2.0.1
lucide-react-native:
specifier: ^0.562.0
- version: 0.562.0(react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 0.562.0(react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
react:
specifier: '*'
- version: 19.2.4
+ version: 19.2.0
react-i18next:
specifier: ^15.7.3
- version: 15.7.4(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
+ version: 15.7.4(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)
react-native:
specifier: ^0.83.2
- version: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ version: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
react-native-edge-to-edge:
specifier: ^1.6.2
- version: 1.8.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 1.8.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
react-native-gesture-handler:
specifier: ^2.30.0
- version: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
react-native-reanimated:
specifier: ~4.2.1
- version: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
react-native-safe-area-context:
specifier: ^5.7.0
- version: 5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
react-native-svg:
specifier: ^15.15.3
- version: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ version: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
react-native-webrtc:
specifier: git+https://github.com/streamplace/react-native-webrtc.git#74fa32266e3a2fee180f5e01bb8753af2a92d9d3
- version: https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
+ version: https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))
react-use-websocket:
specifier: ^4.13.0
version: 4.13.0
@@ -515,7 +515,7 @@ importers:
version: 2.47.2(bufferutil@4.0.8)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)
zustand:
specifier: ^5.0.5
- version: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ version: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
devDependencies:
'@fluent/syntax':
specifier: ^0.19.0
@@ -528,7 +528,7 @@ importers:
version: 2.15.0
i18next-cli:
specifier: ^1.32.0
- version: 1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
+ version: 1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3)
nodemon:
specifier: ^3.1.10
version: 3.1.14
@@ -12355,13 +12355,8 @@ packages:
vue-tsc:
optional: true
- prettier@3.4.2:
- resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==}
- engines: {node: '>=14'}
- hasBin: true
-
- prettier@3.8.1:
- resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
+ prettier@3.8.4:
+ resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==}
engines: {node: '>=14'}
hasBin: true
@@ -15567,7 +15562,7 @@ snapshots:
'@atproto/syntax': 0.5.0
chalk: 4.1.2
commander: 9.5.0
- prettier: 3.4.2
+ prettier: 3.8.4
ts-morph: 24.0.0
yesno: 0.4.0
zod: 3.25.76
@@ -17640,7 +17635,7 @@ snapshots:
'@config-plugins/react-native-webrtc@10.0.0(expo@55.0.6)':
dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
'@croct/json5-parser@0.2.2':
dependencies:
@@ -18120,7 +18115,7 @@ snapshots:
junk: 3.1.0
parse-author: 2.0.0
plist: 3.1.0
- prettier: 3.8.1
+ prettier: 3.8.4
resedit: 2.0.3
resolve: 1.22.11
semver: 7.7.1
@@ -18210,10 +18205,10 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.4)':
+ '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.0)':
dependencies:
emoji-mart: 5.6.0
- react: 19.2.4
+ react: 19.2.0
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -18647,156 +18642,6 @@ snapshots:
- typescript
- utf-8-validate
- '@expo/cli@55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)':
- dependencies:
- '@expo/code-signing-certificates': 0.0.6
- '@expo/config': 55.0.8(typescript@5.9.3)
- '@expo/config-plugins': 55.0.6
- '@expo/devcert': 1.2.1
- '@expo/env': 2.1.1
- '@expo/image-utils': 0.8.12
- '@expo/json-file': 10.0.12
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.9.3)(utf-8-validate@5.0.10)
- '@expo/osascript': 2.4.2
- '@expo/package-manager': 1.10.3
- '@expo/plist': 0.5.2
- '@expo/prebuild-config': 55.0.8(expo@55.0.6)(typescript@5.9.3)
- '@expo/require-utils': 55.0.2(typescript@5.9.3)
- '@expo/router-server': 55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@expo/schema-utils': 55.0.2
- '@expo/spawn-async': 1.7.2
- '@expo/ws-tunnel': 1.0.6
- '@expo/xcpretty': 4.4.1
- '@react-native/dev-middleware': 0.83.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- accepts: 1.3.8
- arg: 5.0.2
- better-opn: 3.0.2
- bplist-creator: 0.1.0
- bplist-parser: 0.3.2
- chalk: 4.1.2
- ci-info: 3.9.0
- compression: 1.8.1
- connect: 3.7.0
- debug: 4.4.3
- dnssd-advertise: 1.1.3
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- expo-server: 55.0.6
- fetch-nodeshim: 0.4.9
- getenv: 2.0.0
- glob: 13.0.6
- lan-network: 0.2.0
- multitars: 0.2.4
- node-forge: 1.3.3
- npm-package-arg: 11.0.3
- ora: 3.4.0
- picomatch: 4.0.3
- pretty-format: 29.7.0
- progress: 2.0.3
- prompts: 2.4.2
- resolve-from: 5.0.0
- semver: 7.7.4
- send: 0.19.2
- slugify: 1.6.6
- source-map-support: 0.5.21
- stacktrace-parser: 0.1.11
- structured-headers: 0.4.1
- terminal-link: 2.1.1
- toqr: 0.1.1
- wrap-ansi: 7.0.0
- ws: 8.19.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- zod: 3.25.76
- optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - '@expo/dom-webview'
- - '@expo/metro-runtime'
- - bufferutil
- - expo-constants
- - expo-font
- - react
- - react-dom
- - react-server-dom-webpack
- - supports-color
- - typescript
- - utf-8-validate
-
- '@expo/cli@55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)':
- dependencies:
- '@expo/code-signing-certificates': 0.0.6
- '@expo/config': 55.0.8(typescript@5.3.3)
- '@expo/config-plugins': 55.0.6
- '@expo/devcert': 1.2.1
- '@expo/env': 2.1.1
- '@expo/image-utils': 0.8.12
- '@expo/json-file': 10.0.12
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.3.3)(utf-8-validate@5.0.10)
- '@expo/osascript': 2.4.2
- '@expo/package-manager': 1.10.3
- '@expo/plist': 0.5.2
- '@expo/prebuild-config': 55.0.8(expo@55.0.6)(typescript@5.3.3)
- '@expo/require-utils': 55.0.2(typescript@5.3.3)
- '@expo/router-server': 55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@expo/schema-utils': 55.0.2
- '@expo/spawn-async': 1.7.2
- '@expo/ws-tunnel': 1.0.6
- '@expo/xcpretty': 4.4.1
- '@react-native/dev-middleware': 0.83.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- accepts: 1.3.8
- arg: 5.0.2
- better-opn: 3.0.2
- bplist-creator: 0.1.0
- bplist-parser: 0.3.2
- chalk: 4.1.2
- ci-info: 3.9.0
- compression: 1.8.1
- connect: 3.7.0
- debug: 4.4.3
- dnssd-advertise: 1.1.3
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- expo-server: 55.0.6
- fetch-nodeshim: 0.4.9
- getenv: 2.0.0
- glob: 13.0.6
- lan-network: 0.2.0
- multitars: 0.2.4
- node-forge: 1.3.3
- npm-package-arg: 11.0.3
- ora: 3.4.0
- picomatch: 4.0.3
- pretty-format: 29.7.0
- progress: 2.0.3
- prompts: 2.4.2
- resolve-from: 5.0.0
- semver: 7.7.4
- send: 0.19.2
- slugify: 1.6.6
- source-map-support: 0.5.21
- stacktrace-parser: 0.1.11
- structured-headers: 0.4.1
- terminal-link: 2.1.1
- toqr: 0.1.1
- wrap-ansi: 7.0.0
- ws: 8.19.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- zod: 3.25.76
- optionalDependencies:
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - '@expo/dom-webview'
- - '@expo/metro-runtime'
- - bufferutil
- - expo-constants
- - expo-font
- - react
- - react-dom
- - react-server-dom-webpack
- - supports-color
- - typescript
- - utf-8-validate
-
'@expo/code-signing-certificates@0.0.6':
dependencies:
node-forge: 1.3.3
@@ -18821,23 +18666,6 @@ snapshots:
'@expo/config-types@55.0.5': {}
- '@expo/config@55.0.8(typescript@5.3.3)':
- dependencies:
- '@expo/config-plugins': 55.0.6
- '@expo/config-types': 55.0.5
- '@expo/json-file': 10.0.12
- '@expo/require-utils': 55.0.2(typescript@5.3.3)
- deepmerge: 4.3.1
- getenv: 2.0.0
- glob: 13.0.6
- resolve-from: 5.0.0
- resolve-workspace-root: 2.0.0
- semver: 7.7.1
- slugify: 1.6.6
- transitivePeerDependencies:
- - supports-color
- - typescript
-
'@expo/config@55.0.8(typescript@5.9.3)':
dependencies:
'@expo/config-plugins': 55.0.6
@@ -18869,38 +18697,12 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- '@expo/devtools@55.0.2(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- chalk: 4.1.2
- optionalDependencies:
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
- '@expo/devtools@55.0.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- chalk: 4.1.2
- optionalDependencies:
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
'@expo/dom-webview@55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- '@expo/dom-webview@55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
- '@expo/dom-webview@55.0.3(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
'@expo/env@2.1.1':
dependencies:
chalk: 4.1.2
@@ -18940,14 +18742,6 @@ snapshots:
'@babel/code-frame': 7.29.0
json5: 2.2.3
- '@expo/local-build-cache-provider@55.0.6(typescript@5.3.3)':
- dependencies:
- '@expo/config': 55.0.8(typescript@5.3.3)
- chalk: 4.1.2
- transitivePeerDependencies:
- - supports-color
- - typescript
-
'@expo/local-build-cache-provider@55.0.6(typescript@5.9.3)':
dependencies:
'@expo/config': 55.0.8(typescript@5.9.3)
@@ -18965,53 +18759,6 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
stacktrace-parser: 0.1.10
- '@expo/log-box@55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- anser: 1.4.10
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- stacktrace-parser: 0.1.10
-
- '@expo/log-box@55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- anser: 1.4.10
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- stacktrace-parser: 0.1.10
-
- '@expo/metro-config@55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.3.3)(utf-8-validate@5.0.10)':
- dependencies:
- '@babel/code-frame': 7.29.0
- '@babel/core': 7.29.0
- '@babel/generator': 7.29.1
- '@expo/config': 55.0.8(typescript@5.3.3)
- '@expo/env': 2.1.1
- '@expo/json-file': 10.0.12
- '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- '@expo/spawn-async': 1.7.2
- browserslist: 4.28.1
- chalk: 4.1.2
- debug: 4.4.3
- getenv: 2.0.0
- glob: 13.0.6
- hermes-parser: 0.32.1
- jsc-safe-url: 0.2.4
- lightningcss: 1.31.1
- picomatch: 4.0.3
- postcss: 8.4.47
- resolve-from: 5.0.0
- optionalDependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - bufferutil
- - supports-color
- - typescript
- - utf-8-validate
-
'@expo/metro-config@55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.9.3)(utf-8-validate@5.0.10)':
dependencies:
'@babel/code-frame': 7.29.0
@@ -19034,7 +18781,7 @@ snapshots:
postcss: 8.4.47
resolve-from: 5.0.0
optionalDependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -19056,38 +18803,6 @@ snapshots:
transitivePeerDependencies:
- '@expo/dom-webview'
- '@expo/metro-runtime@55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- anser: 1.4.10
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- pretty-format: 29.7.0
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- stacktrace-parser: 0.1.10
- whatwg-fetch: 3.6.20
- optionalDependencies:
- react-dom: 19.2.0(react@19.2.4)
- transitivePeerDependencies:
- - '@expo/dom-webview'
- optional: true
-
- '@expo/metro-runtime@55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- anser: 1.4.10
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- pretty-format: 29.7.0
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- stacktrace-parser: 0.1.10
- whatwg-fetch: 3.6.20
- optionalDependencies:
- react-dom: 19.2.0(react@19.2.4)
- transitivePeerDependencies:
- - '@expo/dom-webview'
- optional: true
-
'@expo/metro@54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)':
dependencies:
metro: 0.83.3(bufferutil@4.0.8)(utf-8-validate@5.0.10)
@@ -19128,23 +18843,6 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
- '@expo/prebuild-config@55.0.8(expo@55.0.6)(typescript@5.3.3)':
- dependencies:
- '@expo/config': 55.0.8(typescript@5.3.3)
- '@expo/config-plugins': 55.0.6
- '@expo/config-types': 55.0.5
- '@expo/image-utils': 0.8.12
- '@expo/json-file': 10.0.12
- '@react-native/normalize-colors': 0.83.2
- debug: 4.4.3
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- resolve-from: 5.0.0
- semver: 7.7.1
- xml2js: 0.6.0
- transitivePeerDependencies:
- - supports-color
- - typescript
-
'@expo/prebuild-config@55.0.8(expo@55.0.6)(typescript@5.9.3)':
dependencies:
'@expo/config': 55.0.8(typescript@5.9.3)
@@ -19154,7 +18852,7 @@ snapshots:
'@expo/json-file': 10.0.12
'@react-native/normalize-colors': 0.83.2
debug: 4.4.3
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
resolve-from: 5.0.0
semver: 7.7.1
xml2js: 0.6.0
@@ -19162,16 +18860,6 @@ snapshots:
- supports-color
- typescript
- '@expo/require-utils@55.0.2(typescript@5.3.3)':
- dependencies:
- '@babel/code-frame': 7.29.0
- '@babel/core': 7.29.0
- '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0)
- optionalDependencies:
- typescript: 5.3.3
- transitivePeerDependencies:
- - supports-color
-
'@expo/require-utils@55.0.2(typescript@5.9.3)':
dependencies:
'@babel/code-frame': 7.29.0
@@ -19196,34 +18884,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@expo/router-server@55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
- dependencies:
- debug: 4.4.3
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
- expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- expo-server: 55.0.6
- react: 19.2.4
- optionalDependencies:
- '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react-dom: 19.2.0(react@19.2.4)
- transitivePeerDependencies:
- - supports-color
-
- '@expo/router-server@55.0.10(@expo/metro-runtime@55.0.6)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo-server@55.0.6)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
- dependencies:
- debug: 4.4.3
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3)
- expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- expo-server: 55.0.6
- react: 19.2.4
- optionalDependencies:
- '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react-dom: 19.2.0(react@19.2.4)
- transitivePeerDependencies:
- - supports-color
-
'@expo/schema-utils@55.0.2': {}
'@expo/sdk-runtime-versions@1.0.0': {}
@@ -19240,18 +18900,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- '@expo/vector-icons@15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
- '@expo/vector-icons@15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
'@expo/ws-tunnel@1.0.6': {}
'@expo/xcpretty@4.4.1':
@@ -19657,11 +19305,11 @@ snapshots:
'@floating-ui/core': 1.6.4
'@floating-ui/utils': 0.2.9
- '@floating-ui/react-dom@2.1.3(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@floating-ui/react-dom@2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/dom': 1.6.7
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
'@floating-ui/utils@0.2.9': {}
@@ -19678,13 +19326,6 @@ snapshots:
cached-iterable: 0.3.0
react: 19.2.0
- '@fluent/react@0.15.2(@fluent/bundle@0.19.1)(react@19.2.4)':
- dependencies:
- '@fluent/bundle': 0.19.1
- '@fluent/sequence': 0.8.0(@fluent/bundle@0.19.1)
- cached-iterable: 0.3.0
- react: 19.2.4
-
'@fluent/sequence@0.8.0(@fluent/bundle@0.19.1)':
dependencies:
'@fluent/bundle': 0.19.1
@@ -19765,22 +19406,22 @@ snapshots:
- supports-color
optional: true
- '@gorhom/bottom-sheet@5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@gorhom/bottom-sheet@5.2.8(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
- '@gorhom/portal': 1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ '@gorhom/portal': 1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
invariant: 2.2.4
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-gesture-handler: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
+ react: 19.2.0
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-gesture-handler: 2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
- '@gorhom/portal@1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@gorhom/portal@1.0.14(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
nanoid: 3.3.11
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react: 19.2.0
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
'@grpc/grpc-js@1.10.10':
dependencies:
@@ -20854,264 +20495,264 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
aria-hidden: 1.2.4
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
- react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
- dependencies:
- '@floating-ui/react-dom': 2.1.3(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0)
'@radix-ui/rect': 1.1.1
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slider@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)':
+ '@radix-ui/react-slider@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.2
- '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
'@radix-ui/rect': 1.1.1
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
- '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)':
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.0)':
dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
'@types/react': 19.2.14
@@ -21318,15 +20959,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
- '@react-native/virtualized-lists@0.83.2(@types/react@19.2.14)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
- dependencies:
- invariant: 2.2.4
- nullthrows: 1.1.1
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- optionalDependencies:
- '@types/react': 19.2.14
-
'@react-native/virtualized-lists@0.83.4(@types/react@19.2.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
dependencies:
invariant: 2.2.4
@@ -21483,77 +21115,77 @@ snapshots:
'@reforged/maker-types@2.1.0': {}
- '@rn-primitives/dropdown-menu@1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@rn-primitives/dropdown-menu@1.2.0(@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
- '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@rn-primitives/hooks': 1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@rn-primitives/portal': 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
- '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@rn-primitives/utils': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@rn-primitives/hooks': 1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ '@rn-primitives/portal': 1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
+ '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ '@rn-primitives/utils': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
- react-dom
- '@rn-primitives/hooks@1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@rn-primitives/hooks@1.3.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
- '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react: 19.2.4
+ '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))':
+ '@rn-primitives/portal@1.3.0(@types/react@19.2.14)(immer@10.1.1)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))':
dependencies:
- react: 19.2.4
- zustand: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+ react: 19.2.0
+ zustand: 5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0))
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
transitivePeerDependencies:
- '@types/react'
- immer
- use-sync-external-store
- '@rn-primitives/slider@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@rn-primitives/slider@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
- '@radix-ui/react-slider': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
- '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react: 19.2.4
+ '@radix-ui/react-slider': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@rn-primitives/slot': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ '@rn-primitives/types': 1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
+ react: 19.2.0
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
- react-dom
- '@rn-primitives/slot@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@rn-primitives/slot@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@rn-primitives/types@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@rn-primitives/types@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@rn-primitives/utils@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)':
+ '@rn-primitives/utils@1.2.0(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)':
dependencies:
- react: 19.2.4
+ react: 19.2.0
optionalDependencies:
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
dependencies:
@@ -23889,7 +23521,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.28.6
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -25944,28 +25576,6 @@ snapshots:
- supports-color
- typescript
- expo-asset@55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
- dependencies:
- '@expo/image-utils': 0.8.12
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - supports-color
- - typescript
-
- expo-asset@55.0.8(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3):
- dependencies:
- '@expo/image-utils': 0.8.12
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3)
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - supports-color
- - typescript
-
expo-build-properties@55.0.9(expo@55.0.6):
dependencies:
'@expo/schema-utils': 55.0.2
@@ -25983,26 +25593,6 @@ snapshots:
- supports-color
- typescript
- expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3):
- dependencies:
- '@expo/config': 55.0.8(typescript@5.9.3)
- '@expo/env': 2.1.1
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - supports-color
- - typescript
-
- expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3):
- dependencies:
- '@expo/config': 55.0.8(typescript@5.3.3)
- '@expo/env': 2.1.1
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - supports-color
- - typescript
-
expo-crypto@55.0.9(expo@55.0.6):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -26049,16 +25639,6 @@ snapshots:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- expo-file-system@55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
- expo-file-system@55.0.10(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -26066,20 +25646,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- fontfaceobserver: 2.3.0
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
- expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- fontfaceobserver: 2.3.0
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
expo-image@55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -26089,15 +25655,6 @@ snapshots:
optionalDependencies:
react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- expo-image@55.0.6(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- sf-symbols-typescript: 2.2.0
- optionalDependencies:
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4)
-
expo-json-utils@55.0.0: {}
expo-keep-awake@55.0.4(expo@55.0.6)(react@19.2.0):
@@ -26105,11 +25662,6 @@ snapshots:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react: 19.2.0
- expo-keep-awake@55.0.4(expo@55.0.6)(react@19.2.4):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react: 19.2.4
-
expo-linking@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3):
dependencies:
expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3)
@@ -26136,16 +25688,6 @@ snapshots:
- supports-color
- typescript
- expo-modules-autolinking@55.0.9(typescript@5.3.3):
- dependencies:
- '@expo/require-utils': 55.0.2(typescript@5.3.3)
- '@expo/spawn-async': 1.7.2
- chalk: 4.1.2
- commander: 7.2.0
- transitivePeerDependencies:
- - supports-color
- - typescript
-
expo-modules-autolinking@55.0.9(typescript@5.9.3):
dependencies:
'@expo/require-utils': 55.0.2(typescript@5.9.3)
@@ -26162,18 +25704,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- expo-modules-core@55.0.15(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- invariant: 2.2.4
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
- expo-modules-core@55.0.15(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- invariant: 2.2.4
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
expo-notifications@55.0.12(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.12
@@ -26193,11 +25723,6 @@ snapshots:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- expo-screen-orientation@55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
expo-server@55.0.6: {}
expo-splash-screen@55.0.10(expo@55.0.6)(typescript@5.9.3):
@@ -26215,13 +25740,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- expo-sqlite@55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- await-lock: 2.2.2
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
expo-structured-headers@55.0.0: {}
expo-system-ui@55.0.9(expo@55.0.6)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
@@ -26268,12 +25786,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- expo-video@55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
expo-web-browser@15.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
expo: 55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@5.0.10)
@@ -26326,90 +25838,6 @@ snapshots:
- typescript
- utf-8-validate
- expo@55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10):
- dependencies:
- '@babel/runtime': 7.28.6
- '@expo/cli': 55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)(utf-8-validate@5.0.10)
- '@expo/config': 55.0.8(typescript@5.9.3)
- '@expo/config-plugins': 55.0.6
- '@expo/devtools': 55.0.2(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/fingerprint': 0.16.6
- '@expo/local-build-cache-provider': 55.0.6(typescript@5.9.3)
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.9.3)(utf-8-validate@5.0.10)
- '@expo/vector-icons': 15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@ungap/structured-clone': 1.3.0
- babel-preset-expo: 55.0.11(@babel/core@7.29.0)(@babel/runtime@7.28.6)(expo@55.0.6)(react-refresh@0.14.2)
- expo-asset: 55.0.8(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
- expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3)
- expo-file-system: 55.0.10(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
- expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- expo-keep-awake: 55.0.4(expo@55.0.6)(react@19.2.4)
- expo-modules-autolinking: 55.0.9(typescript@5.9.3)
- expo-modules-core: 55.0.15(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- pretty-format: 29.7.0
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-refresh: 0.14.2
- whatwg-url-minimum: 0.1.1
- optionalDependencies:
- '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react-native-webview: 13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- transitivePeerDependencies:
- - '@babel/core'
- - bufferutil
- - expo-router
- - expo-widgets
- - react-dom
- - react-server-dom-webpack
- - supports-color
- - typescript
- - utf-8-validate
-
- expo@55.0.6(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(react-dom@19.2.0(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10):
- dependencies:
- '@babel/runtime': 7.28.6
- '@expo/cli': 55.0.16(@expo/dom-webview@55.0.3)(@expo/metro-runtime@55.0.6)(bufferutil@4.0.8)(expo-constants@55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3))(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)(utf-8-validate@5.0.10)
- '@expo/config': 55.0.8(typescript@5.3.3)
- '@expo/config-plugins': 55.0.6
- '@expo/devtools': 55.0.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/fingerprint': 0.16.6
- '@expo/local-build-cache-provider': 55.0.6(typescript@5.3.3)
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/metro': 54.2.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- '@expo/metro-config': 55.0.9(bufferutil@4.0.8)(expo@55.0.6)(typescript@5.3.3)(utf-8-validate@5.0.10)
- '@expo/vector-icons': 15.1.1(expo-font@55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@ungap/structured-clone': 1.3.0
- babel-preset-expo: 55.0.11(@babel/core@7.29.0)(@babel/runtime@7.28.6)(expo@55.0.6)(react-refresh@0.14.2)
- expo-asset: 55.0.8(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.3.3)
- expo-constants: 55.0.7(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.3.3)
- expo-file-system: 55.0.10(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))
- expo-font: 55.0.4(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- expo-keep-awake: 55.0.4(expo@55.0.6)(react@19.2.4)
- expo-modules-autolinking: 55.0.9(typescript@5.3.3)
- expo-modules-core: 55.0.15(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- pretty-format: 29.7.0
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-refresh: 0.14.2
- whatwg-url-minimum: 0.1.1
- optionalDependencies:
- '@expo/dom-webview': 55.0.3(expo@55.0.6)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- '@expo/metro-runtime': 55.0.6(@expo/dom-webview@55.0.3)(expo@55.0.6)(react-dom@19.2.0(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react-native-webview: 13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- transitivePeerDependencies:
- - '@babel/core'
- - bufferutil
- - expo-router
- - expo-widgets
- - react-dom
- - react-server-dom-webpack
- - supports-color
- - typescript
- - utf-8-validate
-
exponential-backoff@3.1.1: {}
exponential-backoff@3.1.3: {}
@@ -27838,7 +27266,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.28.6
- i18next-cli@1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(typescript@5.9.3):
+ i18next-cli@1.49.7(@swc/helpers@0.5.17)(@types/node@25.5.0)(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(typescript@5.9.3):
dependencies:
'@croct/json5-parser': 0.2.2
'@swc/core': 1.15.18(@swc/helpers@0.5.17)
@@ -27854,7 +27282,7 @@ snapshots:
minimatch: 10.2.4
ora: 9.3.0
react: 19.2.4
- react-i18next: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
+ react-i18next: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3)
yaml: 2.8.2
transitivePeerDependencies:
- '@swc/helpers'
@@ -29123,12 +28551,6 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
react-native-svg: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
- lucide-react-native@0.562.0(react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-svg: 15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
-
macos-alias@0.2.11:
dependencies:
nan: 2.20.0
@@ -31365,14 +30787,12 @@ snapshots:
prelude-ls@1.2.1: {}
- prettier-plugin-organize-imports@4.3.0(prettier@3.4.2)(typescript@5.9.3):
+ prettier-plugin-organize-imports@4.3.0(prettier@3.8.4)(typescript@5.9.3):
dependencies:
- prettier: 3.4.2
+ prettier: 3.8.4
typescript: 5.9.3
- prettier@3.4.2: {}
-
- prettier@3.8.1: {}
+ prettier@3.8.4: {}
pretty-error@4.0.0:
dependencies:
@@ -31663,11 +31083,6 @@ snapshots:
react: 19.2.0
scheduler: 0.27.0
- react-dom@19.2.0(react@19.2.4):
- dependencies:
- react: 19.2.4
- scheduler: 0.27.0
-
react-freeze@1.0.4(react@19.2.0):
dependencies:
react: 19.2.0
@@ -31683,18 +31098,7 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
typescript: 5.9.3
- react-i18next@15.7.4(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
- dependencies:
- '@babel/runtime': 7.28.6
- html-parse-stringify: 3.0.1
- i18next: 25.8.18(typescript@5.9.3)
- react: 19.2.4
- optionalDependencies:
- react-dom: 19.2.0(react@19.2.4)
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- typescript: 5.9.3
-
- react-i18next@16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
+ react-i18next@16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.6
html-parse-stringify: 3.0.1
@@ -31702,8 +31106,8 @@ snapshots:
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
- react-dom: 19.2.0(react@19.2.4)
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
+ react-dom: 19.2.0(react@19.2.0)
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
typescript: 5.9.3
react-is@16.13.1: {}
@@ -31726,11 +31130,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-edge-to-edge@1.8.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
react-native-fit-image@1.5.5:
dependencies:
prop-types: 15.8.1
@@ -31743,14 +31142,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- '@egjs/hammerjs': 2.0.17
- hoist-non-react-statics: 3.3.2
- invariant: 2.2.4
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
react-native-haptic-feedback@2.3.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
@@ -31761,11 +31152,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-is-edge-to-edge@1.2.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
react-native-localize@3.7.0(@expo/config-plugins@55.0.6)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
react: 19.2.0
@@ -31795,24 +31181,11 @@ snapshots:
react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0)
semver: 7.7.3
- react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- react-native-worklets: 0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- semver: 7.7.3
-
react-native-safe-area-context@5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-safe-area-context@5.7.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
-
react-native-screens@4.24.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
react: 19.2.0
@@ -31837,14 +31210,6 @@ snapshots:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
warn-once: 0.1.1
- react-native-svg@15.15.3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- css-select: 5.1.0
- css-tree: 1.1.3
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- warn-once: 0.1.1
-
react-native-url-polyfill@3.0.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
@@ -31865,22 +31230,6 @@ snapshots:
transitivePeerDependencies:
- encoding
- react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.4))(react@19.2.4):
- dependencies:
- '@babel/runtime': 7.28.6
- '@react-native/normalize-colors': 0.74.85
- fbjs: 3.0.5(encoding@0.1.13)
- inline-style-prefixer: 7.0.1
- memoize-one: 6.0.0
- nullthrows: 1.1.1
- postcss-value-parser: 4.2.0
- react: 19.2.4
- react-dom: 19.2.0(react@19.2.4)
- styleq: 0.1.3
- transitivePeerDependencies:
- - encoding
- optional: true
-
react-native-webrtc@https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)):
dependencies:
base64-js: 1.5.1
@@ -31890,15 +31239,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-native-webrtc@https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
- dependencies:
- base64-js: 1.5.1
- debug: 4.3.4
- event-target-shim: 6.0.2
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- transitivePeerDependencies:
- - supports-color
-
react-native-webrtc@https://codeload.github.com/streamplace/react-native-webrtc/tar.gz/74fa32266e3a2fee180f5e01bb8753af2a92d9d3(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)):
dependencies:
base64-js: 1.5.1
@@ -31915,22 +31255,6 @@ snapshots:
react: 19.2.0
react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10)
- react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- escape-string-regexp: 4.0.0
- invariant: 2.2.4
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- optional: true
-
- react-native-webview@13.16.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- escape-string-regexp: 4.0.0
- invariant: 2.2.4
- react: 19.2.4
- react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- optional: true
-
react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10))(react@19.2.0):
dependencies:
'@babel/core': 7.29.0
@@ -31950,25 +31274,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4):
- dependencies:
- '@babel/core': 7.29.0
- '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
- '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.29.0)
- '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.29.0)
- '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.29.0)
- '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.29.0)
- '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0)
- '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0)
- '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0)
- '@babel/preset-typescript': 7.27.1(@babel/core@7.29.0)
- convert-source-map: 2.0.0
- react: 19.2.4
- react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10)
- semver: 7.7.3
- transitivePeerDependencies:
- - supports-color
-
react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.0)(utf-8-validate@5.0.10):
dependencies:
'@jest/create-cache-key-function': 29.7.0
@@ -32017,54 +31322,6 @@ snapshots:
- supports-color
- utf-8-validate
- react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10):
- dependencies:
- '@jest/create-cache-key-function': 29.7.0
- '@react-native/assets-registry': 0.83.2
- '@react-native/codegen': 0.83.2(@babel/core@7.29.0)
- '@react-native/community-cli-plugin': 0.83.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- '@react-native/gradle-plugin': 0.83.2
- '@react-native/js-polyfills': 0.83.2
- '@react-native/normalize-colors': 0.83.2
- '@react-native/virtualized-lists': 0.83.2(@types/react@19.2.14)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10))(react@19.2.4)
- abort-controller: 3.0.0
- anser: 1.4.10
- ansi-regex: 5.0.1
- babel-jest: 29.7.0(@babel/core@7.29.0)
- babel-plugin-syntax-hermes-parser: 0.32.0
- base64-js: 1.5.1
- commander: 12.1.0
- flow-enums-runtime: 0.0.6
- glob: 7.2.3
- hermes-compiler: 0.14.1
- invariant: 2.2.4
- jest-environment-node: 29.7.0
- memoize-one: 5.2.1
- metro-runtime: 0.83.5
- metro-source-map: 0.83.5
- nullthrows: 1.1.1
- pretty-format: 29.7.0
- promise: 8.3.0
- react: 19.2.4
- react-devtools-core: 6.1.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- react-refresh: 0.14.2
- regenerator-runtime: 0.13.11
- scheduler: 0.27.0
- semver: 7.7.1
- stacktrace-parser: 0.1.10
- whatwg-fetch: 3.6.20
- ws: 7.5.10(bufferutil@4.0.8)(utf-8-validate@5.0.10)
- yargs: 17.7.2
- optionalDependencies:
- '@types/react': 19.2.14
- transitivePeerDependencies:
- - '@babel/core'
- - '@react-native-community/cli'
- - '@react-native/metro-config'
- - bufferutil
- - supports-color
- - utf-8-validate
-
react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.0.8)(react@19.2.4)(utf-8-validate@5.0.10):
dependencies:
'@jest/create-cache-key-function': 29.7.0
@@ -32117,29 +31374,29 @@ snapshots:
react-refresh@0.4.3: {}
- react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.0):
dependencies:
- react: 19.2.4
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.0
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
- react-remove-scroll@2.7.1(@types/react@19.2.14)(react@19.2.4):
+ react-remove-scroll@2.7.1(@types/react@19.2.14)(react@19.2.0):
dependencies:
- react: 19.2.4
- react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4)
- react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
+ react: 19.2.0
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.0)
+ react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0)
tslib: 2.8.1
- use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
- use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
+ use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.0)
+ use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.14
- react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
+ react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.0):
dependencies:
get-nonce: 1.0.1
- react: 19.2.4
+ react: 19.2.0
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
@@ -34265,9 +33522,9 @@ snapshots:
url-template@3.1.1: {}
- use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
+ use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.0):
dependencies:
- react: 19.2.4
+ react: 19.2.0
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
@@ -34276,10 +33533,10 @@ snapshots:
dependencies:
react: 19.2.0
- use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
+ use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.0):
dependencies:
detect-node-es: 1.1.0
- react: 19.2.4
+ react: 19.2.0
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
@@ -34975,11 +34232,4 @@ snapshots:
react: 19.2.0
use-sync-external-store: 1.6.0(react@19.2.0)
- zustand@5.0.11(@types/react@19.2.14)(immer@10.1.1)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
- optionalDependencies:
- '@types/react': 19.2.14
- immer: 10.1.1
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
-
zwitch@2.0.4: {}
--
2.51.2
From 68f6635706e131e2cde32fc7b6d4432dff4ba0f2 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Thu, 25 Jun 2026 11:18:46 -0700
Subject: [PATCH 12/34] live-vod: only record published segments to S3; cut
over on unpublish
S3 recording ran before the published gate, so segments arriving after a
livestream ended (or before it started) kept appending to the same rolling
object, tagged with the just-ended livestream URI, and that object wasn't
completed until the cutoverEvery timer or stream teardown. That produced
either "no recorded S3 segments" (finalize counts only completed objects)
or an over-long VOD (the post-stop tail got absorbed into the recording).
Gate s3Upload on notif.Metadata.Published (an un-ended place.stream.livestream
record), and on unpublished segments call the new S3Uploader.Cutover to
complete the current object immediately so it's finalize-able right away.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
pkg/director/s3_upload.go | 14 ++++++++++
pkg/director/stream_session.go | 13 ++++++++-
pkg/s3/s3.go | 48 +++++++++++++++++++++++++++++-----
pkg/s3/uploader_test.go | 29 ++++++++++++++++++++
4 files changed, 97 insertions(+), 7 deletions(-)
diff --git a/pkg/director/s3_upload.go b/pkg/director/s3_upload.go
index 711d4ef7..5cc73019 100644
--- a/pkg/director/s3_upload.go
+++ b/pkg/director/s3_upload.go
@@ -49,6 +49,20 @@ func (ss *StreamSession) s3Upload(ctx context.Context, notif *media.NewSegmentNo
})
}
+// s3Cutover completes the current live-recording object so it's immediately
+// finalize-able. Called when a segment arrives that is not part of a live
+// (published) stream — i.e. the livestream just ended, or hasn't started — so
+// the recording is closed out promptly rather than lingering un-completed until
+// stream teardown.
+func (ss *StreamSession) s3Cutover(ctx context.Context) {
+ if ss.s3Uploader == nil {
+ return
+ }
+ ss.Go(ctx, func() error {
+ return ss.s3Uploader.Cutover(ctx)
+ })
+}
+
func (ss *StreamSession) s3Close(ctx context.Context) {
if ss.s3Uploader == nil {
return
diff --git a/pkg/director/stream_session.go b/pkg/director/stream_session.go
index 5642b8af..8f39be58 100644
--- a/pkg/director/stream_session.go
+++ b/pkg/director/stream_session.go
@@ -242,7 +242,18 @@ func (ss *StreamSession) NewSegment(ctx context.Context, notif *media.NewSegment
return fmt.Errorf("could not convert segment to streamplace segment: %w", err)
}
- ss.s3Upload(ctx, notif)
+ // Record to S3 for live-to-VOD only while the stream is live (an un-ended
+ // place.stream.livestream record exists -> the segment is published).
+ // Segments that arrive before "go live" or after the livestream ends are
+ // NOT recorded: pushing them produced over-long VODs (the post-stop tail
+ // got absorbed into the recording). The moment publishing stops we complete
+ // the current object so it's immediately finalize-able instead of lingering
+ // un-completed (which made finalize report "no recorded S3 segments").
+ if notif.Metadata.Published {
+ ss.s3Upload(ctx, notif)
+ } else {
+ ss.s3Cutover(ctx)
+ }
ss.bus.Publish(spseg.Creator, spseg)
ss.Go(ctx, func() error {
diff --git a/pkg/s3/s3.go b/pkg/s3/s3.go
index 008ee1cc..eb475578 100644
--- a/pkg/s3/s3.go
+++ b/pkg/s3/s3.go
@@ -57,7 +57,7 @@ type S3Uploader struct {
cutoverEvery time.Duration
keyPrefix string // e.g. "did:plc:abc123/"
userDID string
- segCh chan []byte // bare canonical MUXL segments awaiting upload
+ segCh chan uploadCmd // bare canonical MUXL segments / cutover requests
done chan error
recorder Recorder
@@ -136,7 +136,7 @@ func newS3Uploader(client uploadAPI, bucket, userDID, keyPrefix string, cutoverE
cutoverEvery: cutoverEvery,
keyPrefix: keyPrefix,
userDID: userDID,
- segCh: make(chan []byte, 16),
+ segCh: make(chan uploadCmd, 16),
done: make(chan error, 1),
recorder: recorder,
}
@@ -144,6 +144,14 @@ func newS3Uploader(client uploadAPI, bucket, userDID, keyPrefix string, cutoverE
return u
}
+// uploadCmd is one item on segCh: either a segment to append (seg != nil) or a
+// request to complete the current object now (cutover). Both travel the same
+// channel so a cutover stays FIFO-ordered behind the segments queued before it.
+type uploadCmd struct {
+ seg []byte // bare canonical MUXL segment to append; nil for a cutover
+ cutover bool // complete the current object now (see Cutover)
+}
+
// AddSegment feeds one bare canonical MUXL segment (uuid+moof+mdat per track)
// for upload. The bytes are copied, so the caller may reuse its buffer. It is
// the caller's responsibility not to call AddSegment concurrently with Close
@@ -156,7 +164,26 @@ func (u *S3Uploader) AddSegment(ctx context.Context, data []byte) error {
}
seg := append([]byte(nil), data...)
select {
- case u.segCh <- seg:
+ case u.segCh <- uploadCmd{seg: seg}:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+// Cutover completes the current in-progress object (if any) so it becomes a
+// finalize-able, completed S3 segment, without tearing the uploader down — the
+// next AddSegment simply starts a fresh object. It's used when a livestream
+// ends (or the stream goes unpublished): the recording is closed out promptly
+// instead of waiting for the cutoverEvery timer or stream teardown, so finalize
+// can find the completed objects right away. A no-op if there's no current
+// object, and a no-op after Close.
+func (u *S3Uploader) Cutover(ctx context.Context) error {
+ if u.closed.Load() {
+ return nil
+ }
+ select {
+ case u.segCh <- uploadCmd{cutover: true}:
return nil
case <-ctx.Done():
return ctx.Err()
@@ -325,7 +352,7 @@ func (u *S3Uploader) uploadLoop(ctx context.Context) {
var err error
for err == nil {
select {
- case seg, ok := <-u.segCh:
+ case cmd, ok := <-u.segCh:
if !ok {
// No more segments; complete any in-progress upload.
err = completeUpload()
@@ -335,8 +362,17 @@ func (u *S3Uploader) uploadLoop(ctx context.Context) {
u.done <- err
return
}
- log.Debug(ctx, "received segment for S3 upload", "size", len(seg))
- if err = handleSegment(seg); err != nil {
+ if cmd.cutover {
+ // Close out the current object so it's immediately finalize-able
+ // (e.g. the livestream just ended). No-op if nothing is in flight.
+ if err = completeUpload(); err != nil {
+ err = fmt.Errorf("error completing upload on cutover: %w", err)
+ log.Error(ctx, "error completing upload on cutover", "error", err)
+ }
+ continue
+ }
+ log.Debug(ctx, "received segment for S3 upload", "size", len(cmd.seg))
+ if err = handleSegment(cmd.seg); err != nil {
log.Error(ctx, "error handling segment", "error", err)
}
diff --git a/pkg/s3/uploader_test.go b/pkg/s3/uploader_test.go
index 69a498e0..523fd3da 100644
--- a/pkg/s3/uploader_test.go
+++ b/pkg/s3/uploader_test.go
@@ -116,6 +116,35 @@ func TestS3UploaderCutoverOnLivestreamChange(t *testing.T) {
require.NotEqual(t, keys[0], keys[1], "rolled-over objects must have distinct keys")
}
+// TestS3UploaderCutoverCompletesObject proves Cutover closes out the current
+// object so the next segment starts a fresh one. This is what makes a recording
+// finalize-able the moment a livestream ends, rather than lingering until the
+// cutoverEvery timer or stream teardown. cutoverEvery is huge so only the
+// explicit Cutover can trigger the rollover.
+func TestS3UploaderCutoverCompletesObject(t *testing.T) {
+ fc := &fakeUploadAPI{}
+ rec := &fakeRecorder{}
+ u := newS3Uploader(fc, "bucket", "did:plc:test", "did:plc:test/", time.Hour, rec)
+
+ ctx := context.Background()
+ seg := make([]byte, 1024) // under minPartSize: buffered until the object completes
+
+ u.SetLivestreamURI("at://A")
+ require.NoError(t, u.AddSegment(ctx, seg))
+ waitForStarts(t, rec, 1) // object 1
+
+ require.NoError(t, u.Cutover(ctx)) // completes object 1
+ require.NoError(t, u.AddSegment(ctx, seg))
+ waitForStarts(t, rec, 2) // object 2
+
+ require.NoError(t, u.Close(ctx))
+
+ require.Equal(t, 2, rec.count(), "Cutover must close the object so the next segment starts a new one")
+ keys := rec.startKeys()
+ require.Len(t, keys, 2)
+ require.NotEqual(t, keys[0], keys[1], "post-cutover object must have a distinct key")
+}
+
// TestS3UploaderCloseIdempotent exercises the lifecycle fix that re-enabled
// live S3 upload: Close must be safe to call repeatedly and concurrently (it
// was a plain close(segCh) before, which panicked on the second call), and
--
2.51.2
From a48f33e038a11edfb0a5f41ea80b704bafa173e3 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Thu, 25 Jun 2026 11:20:09 -0700
Subject: [PATCH 13/34] upload: fix button width
---
js/app/src/screens/upload.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx
index b41981f2..9a846bb7 100644
--- a/js/app/src/screens/upload.tsx
+++ b/js/app/src/screens/upload.tsx
@@ -1050,7 +1050,11 @@ export default function UploadScreen() {
) : (
-
+ );
+}
+
+// ── 4. /upload/livestreams — livestream list + finalize ──────────────────────
+
+export function UploadLivestreamsScreen() {
+ const gate = useUploadGate();
+ const { theme } = useTheme();
+ const navigation = useNavigation();
+
+ const [livestreams, setLivestreams] = useState([]);
+ const [livestreamsLoading, setLivestreamsLoading] = useState(false);
+ const [userVideos, setUserVideos] = useState([]);
+ // Per-livestream finalize state. With draft VODs, finalize no longer polls
+ // or publishes: it just kicks off the server task and flips the row to
+ // "done" so the user knows to find the draft in the Drafts tab.
+ const [finalizing, setFinalizing] = useState<
+ Record
+ >({});
+ // Livestream URI -> draft ats:// URI, recorded when finalizeLivestream
+ // returns so the row can offer a deep-link to the draft editor.
+ const [finalizedDraftUris, setFinalizedDraftUris] = useState<
+ Record
+ >({});
+
+ const agent = gate.state === "ok" ? gate.agent : null;
+
+ const fetchVideos = useCallback(async () => {
+ if (!agent || !agent.did) return;
+ try {
+ const res = await agent.place.stream.media.getVideoList({
+ repo: agent.did,
+ });
+ setUserVideos(res.data.videos || []);
+ } catch (err) {
+ console.error("Failed to fetch videos", err);
+ }
+ }, [agent]);
+
+ const fetchLivestreams = useCallback(async () => {
+ if (!agent || !agent.did) return;
+ setLivestreamsLoading(true);
+ try {
+ const res = await agent.com.atproto.repo.listRecords({
+ repo: agent.did,
+ collection: "place.stream.livestream",
+ limit: 100,
+ });
+ const records = (res.data.records ?? []).slice().sort((a, b) => {
+ const at = ((a.value as any)?.createdAt as string) ?? "";
+ const bt = ((b.value as any)?.createdAt as string) ?? "";
+ return bt.localeCompare(at); // newest first
+ });
+ setLivestreams(records);
+ } catch (err) {
+ console.error("Failed to fetch livestreams", err);
+ } finally {
+ setLivestreamsLoading(false);
+ }
+ }, [agent]);
+
+ // Cross-reference the user's published videos against their livestreams via
+ // the video record's `connections` array: a finalized livestream is one some
+ // VOD links back to. media.publishVideo preserves client-supplied
+ // connections, so the finalize flow below stamps the source livestream there.
+ const livestreamToVideo = useMemo(() => {
+ const map = new Map();
+ for (const v of userVideos) {
+ const rec = (v.record?.value || v.record || {}) as any;
+ for (const c of rec.connections ?? []) {
+ const uri = c?.ref?.uri;
+ if (
+ typeof uri === "string" &&
+ uri.includes("/place.stream.livestream/")
+ ) {
+ map.set(uri, v.uri);
+ }
+ }
+ }
+ return map;
+ }, [userVideos]);
+
+ useEffect(() => {
+ if (gate.state === "ok") {
+ fetchVideos();
+ fetchLivestreams();
+ }
+ }, [gate.state, fetchVideos, fetchLivestreams]);
+
+ // Finalize a single livestream into a draft VOD. With draft VODs the
+ // client no longer polls getUploadStatus or calls publishVideo: the server
+ // kicks off the remux/processing task and creates a draft record (status
+ // "processing" → "ready") that the user publishes later from the Drafts tab.
+ // We just call finalizeLivestream and flip the row to a "done" state that
+ // points the user at the draft editor.
+ const finalizeLivestreamRow = useCallback(
+ async (ls: { uri: string; cid: string; value: any }) => {
+ if (!agent || !agent.did) return;
+ setFinalizing((m) => ({ ...m, [ls.uri]: "finalizing" }));
+ try {
+ const res = await agent.place.stream.media.finalizeLivestream({
+ livestream: ls.uri,
+ });
+ if (!res.success) throw new Error("finalizeLivestream failed");
+ // Record the draft URI so the row can offer a deep-link to the
+ // draft editor.
+ if (res.data.draftUri) {
+ setFinalizedDraftUris((m) => ({
+ ...m,
+ [ls.uri]: res.data.draftUri as string,
+ }));
+ }
+ setFinalizing((m) => ({ ...m, [ls.uri]: "done" }));
+ } catch (err) {
+ console.error("Failed to finalize livestream", err);
+ setFinalizing((m) => ({ ...m, [ls.uri]: "error" }));
+ }
+ },
+ [agent],
+ );
+
+ if (gate.state !== "ok") {
+ return {null};
+ }
+
+ return (
+
+
+
+
+
+ {livestreamsLoading && livestreams.length === 0 && (
+
+
+
+ )}
+ {!livestreamsLoading && livestreams.length === 0 && (
+
+
+
+ No livestreams yet. When you go live, your past streams show up
+ here to finalize into VODs.
+
+
+ )}
+ {livestreams.map((ls: any) => {
+ const rec = ls.value || {};
+ const ended = !!rec.endedAt;
+ const vodUri = livestreamToVideo.get(ls.uri);
+ const status = finalizing[ls.uri];
+ const draftU = finalizedDraftUris[ls.uri];
+ return (
+
+
+
+ {rec.title || "Untitled livestream"}
+
+
+ {rec.createdAt
+ ? new Date(rec.createdAt).toLocaleString()
+ : ""}
+ {ended ? " · Ended" : " · Live"}
+
+
+ {vodUri ? (
+
-
-
- {action === "publishing" ? "Publishing…" : "Deleting…"}
+
+
+ View VOD
- ) : status === "ready" ? (
-
- handleSelectDraft(draft)}
- >
- Edit
-
- handlePublishDraft(draft.uri)}
- >
- Publish
-
- handleDeleteDraft(draft.uri)}
- >
-
-
-
- ) : status === "error" ? (
- handleDeleteDraft(draft.uri)}
- >
- Discard
-
- ) : (
- handleDeleteDraft(draft.uri)}
- >
-
-
- )}
-
- );
- })}
-
- )}
-
- {/* Upload/Edit mode */}
- {managerMode === "upload" && (
- <>
- {(editingVideoUri || editingDraftUri) && (
-
-
- {editingDraftUri ? "Editing draft" : "Editing video"}
-
-
- {editingVideoUri && (
-
-
- {deleting ? "Deleting…" : "Delete"}
-
-
- )}
+
+ ) : status === "finalizing" ? (
+
+
+
+ Finalizing…
+
+
+ ) : status === "done" ? (
{
- setEditingVideoUri(undefined);
- setEditingDraftUri(undefined);
- setPhase({ kind: "idle" });
- setFile(null);
- setTitle("");
- setDescription("");
- setActivity(undefined);
- setTags([]);
- setTagInput("");
- setThumbnail(undefined);
- setThumbnailUrl(undefined);
- setWarnings(new Set());
- setLicense(
- "place.stream.metadata.contentRights#all-rights-reserved",
- );
+ if (draftU) {
+ navigation.navigate("UploadVideo" as any, {
+ tid: tidFromUri(draftU),
+ });
+ } else {
+ navigation.navigate("UploadDrafts" as any);
+ }
+ }}
+ style={{
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
}}
>
-
+
+
+ Edit draft →
+
-
+ ) : !ended ? (
+
+ {}}>
+ Finalize
+
+
+ ) : (
+ finalizeLivestreamRow(ls)}
+ style={{ width: "auto" }}
+ >
+
+ {status === "error" ? "Retry" : "Finalize"}
+
+
+ )}
- )}
-
- {/* ── left column: metadata ────────────────────────────────────── */}
-
-
- {/* title + description */}
-
- Details
-
-
-
- Title
-
-
-
-
-
-
- Description
-
-
-
-
-
+ );
+ })}
+
+
+
+ );
+}
- {/* activity */}
-
- Activity
-
-
-
-
-
-
+// ── 5. /upload/videos — My Videos list ───────────────────────────────────────
- {/* tags */}
-
- Tags
-
-
- {tags.length > 0 && (
-
- {tags.map((tag) => (
-
- setTags(tags.filter((t) => t !== tag))
- }
- style={{
- flexDirection: "row",
- alignItems: "center",
- backgroundColor: theme.colors.primary + "22",
- borderRadius: 12,
- paddingHorizontal: 10,
- paddingVertical: 3,
- }}
- >
-
- {tag}
-
-
- ×
-
-
- ))}
-
- )}
- {tags.length < 10 && (
-
- setTagInput(v.replace(/[^a-zA-Z0-9:]/g, ""))
- }
- placeholder="Add tag, press Enter"
- placeholderTextColor={theme.colors.mutedForeground}
- returnKeyType="done"
- onSubmitEditing={() => {
- const t = tagInput.trim();
- if (t && !tags.includes(t)) setTags([...tags, t]);
- setTagInput("");
- }}
- style={inputStyle(theme)}
- />
- )}
-
-
-
+export function UploadVideosScreen() {
+ const gate = useUploadGate();
+ const { theme } = useTheme();
+ const navigation = useNavigation();
- {/* content warnings */}
-
- Content Warnings
-
-
- {CONTENT_WARNINGS.map((cw) => (
-
- toggleWarning(cw.value)}
- label={cw.label}
- size="sm"
- />
-
- ))}
-
-
-
+ const [userVideos, setUserVideos] = useState([]);
- {/* license */}
-
- License
-
-
-
-
-
-
-
+ const agent = gate.state === "ok" ? gate.agent : null;
- {/* ── right column: file + status + actions ────────────────────── */}
- {!editingVideoUri && !editingDraftUri && (
-
-
-
- {/* file picker */}
- File
-
-
-
-
- {file ? file.name : "Choose a video file"}
-
- {file && (
-
- {file.type || "unknown"} — {humanBytes(file.size)}
-
- )}
-
-
-
- {/* thumbnail */}
-
- Thumbnail
-
- {thumbnailUrl ? (
-
-
-
-
-
-
- ) : (
-
-
-
- Add thumbnail image
-
-
- Optional · JPG, PNG up to 975KB
-
-
- )}
- {thumbnail && thumbnail.size > 975000 && (
-
-
- Heads up: this image is larger than 975KB.
- Upload might fail.
-
-
- )}
-
-
-
-
- {/* status */}
- {phase.kind !== "idle" && (
-
- Status
-
- {phase.kind === "creating" && (
-
-
-
-
- Preparing upload…
-
-
-
-
-
-
- )}
- {phase.kind === "uploading" && (
-
-
-
-
- {phase.pct.toFixed(1)}% —{" "}
- {humanBytes(phase.bytesSent)} /{" "}
- {humanBytes(phase.bytesTotal)}
-
-
-
-
-
-
- )}
- {(phase.kind === "processing" ||
- phase.kind === "publishing") && (
-
-
-
-
- {phase.kind === "processing"
- ? phase.serverStatus === "processing"
- ? `Processing video${phase.progress != null ? ` (${phase.progress}%)` : "…"}`
- : "Waiting to process…"
- : "Publishing…"}
-
-
- {phase.kind === "processing" &&
- phase.progress != null ? (
-
-
-
- ) : (
-
-
-
- )}
-
- )}
- {phase.kind === "ready" && (
-
-
-
- Ready to publish
-
-
- )}
- {phase.kind === "done" && (
-
-
-
-
- Upload complete
-
-
- setManagerMode("drafts")}
- style={{
- flexDirection: "row",
- alignItems: "center",
- gap: 6,
- }}
- >
-
- Find your draft in the Drafts tab to add
- details and publish →
-
-
-
- )}
- {phase.kind === "error" && (
-
-
-
- {phase.message}
-
-
- )}
-
-
- )}
-
- {/* actions */}
-
-
- {editingDraftUri ? (
-
- {updating ? "Saving…" : "Save Draft"}
-
- ) : editingVideoUri ? (
-
- {updating ? "Updating…" : "Update Video"}
-
- ) : (
- <>
- {canUpload && (
- Upload
- )}
- {!file && phase.kind === "idle" && (
-
- Choose file
-
- )}
- {isUploading && phase.kind !== "processing" && (
-
- Cancel
-
- )}
- {phase.kind === "done" && (
- {
- setFile(null);
- setPhase({ kind: "idle" });
- setTitle("");
- setDescription("");
- setActivity(undefined);
- setTags([]);
- setTagInput("");
- setThumbnail(undefined);
- setThumbnailUrl(undefined);
- setWarnings(new Set());
- setLicense(
- "place.stream.metadata.contentRights#all-rights-reserved",
- );
- }}
- variant="secondary"
- >
- Upload another
-
- )}
- >
- )}
-
-
-
-
-
- )}
+ const fetchVideos = useCallback(async () => {
+ if (!agent || !agent.did) return;
+ try {
+ const res = await agent.place.stream.media.getVideoList({
+ repo: agent.did,
+ });
+ setUserVideos(res.data.videos || []);
+ } catch (err) {
+ console.error("Failed to fetch videos", err);
+ }
+ }, [agent]);
+
+ useEffect(() => {
+ if (gate.state === "ok") fetchVideos();
+ }, [gate.state, fetchVideos]);
+
+ if (gate.state !== "ok") {
+ return {null};
+ }
+
+ return (
+
+
+
+
+
+ {userVideos.length === 0 && (
+
+
+
+ No videos yet. Switch to the Upload tab to add your first video.
+
- >
- )}
+ )}
+ {userVideos.map((video: any) => {
+ const rec = video.record?.value || video.record || {};
+ const thumb = rec.thumb;
+ const thumbUrl = thumb
+ ? `https://cdn.stream.place/thumb/${thumb.ref?.$link || thumb.cid || ""}`
+ : undefined;
+ return (
+
+ navigation.navigate("UploadVideo" as any, {
+ tid: tidFromUri(video.uri),
+ })
+ }
+ style={[
+ zero.p[3],
+ zero.r.md,
+ {
+ flexDirection: "row",
+ gap: 12,
+ borderWidth: 1,
+ borderColor: theme.colors.border,
+ backgroundColor: theme.colors.background,
+ alignItems: "center",
+ },
+ ]}
+ >
+ {thumbUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {rec.title || "Untitled"}
+
+
+ {video.viewCounts?.count != null &&
+ `${video.viewCounts.count} views · `}
+ {rec.durationMs
+ ? `${Math.round(rec.durationMs / 1000)}s`
+ : ""}
+
+
+
+ );
+ })}
+
-
-
);
}
-
-// ── style helpers ─────────────────────────────────────────────────────────────
-
-function inputStyle(theme: any) {
- return {
- borderWidth: 1,
- borderColor: theme.colors.border,
- borderRadius: 8,
- padding: 8,
- color: theme.colors.foreground,
- backgroundColor: theme.colors.background,
- };
-}
diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx
index b3f7cc4a..47aecda5 100644
--- a/js/app/src/shell.tsx
+++ b/js/app/src/shell.tsx
@@ -66,7 +66,12 @@ import PopoutLivestream from "src/screens/popout-livestream";
import PopoutMultistream from "src/screens/popout-multistream";
import PopoutStreamMonitor from "src/screens/popout-stream-monitor";
import SupportScreen from "src/screens/support";
-import UploadScreen from "src/screens/upload";
+import UploadScreen, {
+ UploadDraftsScreen,
+ UploadLivestreamsScreen,
+ UploadVideoScreen,
+ UploadVideosScreen,
+} from "src/screens/upload";
import VideoScreen from "src/screens/video";
import VideoListScreen from "src/screens/video-list";
import VodScreen from "src/screens/vod";
@@ -219,6 +224,34 @@ function HomeNavigator() {
options={{ title: "Upload Video", ...headerScreenOptions }}
/>
)}
+ {!isNative && (
+
+ )}
+ {!isNative && (
+
+ )}
+ {!isNative && (
+
+ )}
+ {!isNative && (
+
+ )}
);
}
--
2.51.2
From ad94d87b25e8e092fb994f939de03c974233ba14 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 26 Jun 2026 17:48:17 -0700
Subject: [PATCH 25/34] draft-vods: create-draft-first upload flow (createDraft
XRPC + createUpload assoc)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Make the draft the durable anchor for an upload: the client creates an empty
draft up front, then passes its URI to createUpload so processing fills that
draft. Lets the user edit metadata while the upload runs, and supports
re-upload (a failed upload leaves the draft intact; a new createUpload with the
same draftUri re-points it).
- New XRPC place.stream.vod.createDraft (procedure, no input -> {uri}):
creates an empty 'processing' draft (title 'Untitled') and returns its
ats:// URI. Banned accounts rejected.
- createUpload lexicon gains optional draftUri param. When supplied, the
handler calls SetDraftOriginUpload(draftUri, did, uploadID) to re-point
the draft's origin_upload_id at this upload. A missing/stale draft is
non-fatal (logged) — the upload proceeds and the legacy auto-create path
runs.
- pkg/upload/upload.go TUS completion: only auto-create a draft when no draft
is already associated with the upload (GetDraftByUpload returns nil). So
legacy clients without draftUri still get a draft; the modern flow's
pre-created draft is reused, not duplicated.
- statedb.SetDraftOriginUpload: re-points a draft at a (new) uploadID,
ownership-scoped (user_did must match). Returns ErrRecordNotFound if absent.
markDraftReadyFromUpload(uploadID) is unchanged — it already finds the draft
by origin_upload_id, so the pre-created draft (now re-pointed) is filled on
completion. build + tests + lint clean.
Co-authored-by: Claude Opus 4.8
---
.../media/place-stream-media-createupload.md | 15 ++--
.../content/docs/lex-reference/openapi.json | 41 +++++++++++
.../vod/place-stream-vod-createdraft.md | 73 +++++++++++++++++++
lexicons/place/stream/media/createUpload.json | 4 +
lexicons/place/stream/vod/createDraft.json | 30 ++++++++
pkg/spxrpc/place_stream_media.go | 14 ++++
pkg/spxrpc/place_stream_vod_drafts.go | 28 +++++++
pkg/spxrpc/stubs.go | 19 +++++
pkg/statedb/draft_video.go | 19 +++++
pkg/streamplace/mediacreateUpload.go | 2 +
pkg/streamplace/vodcreateDraft.go | 31 ++++++++
pkg/upload/upload.go | 29 +++++---
12 files changed, 288 insertions(+), 17 deletions(-)
create mode 100644 js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md
create mode 100644 lexicons/place/stream/vod/createDraft.json
create mode 100644 pkg/streamplace/vodcreateDraft.go
diff --git a/js/docs/src/content/docs/lex-reference/media/place-stream-media-createupload.md b/js/docs/src/content/docs/lex-reference/media/place-stream-media-createupload.md
index 1255526e..406849f9 100644
--- a/js/docs/src/content/docs/lex-reference/media/place-stream-media-createupload.md
+++ b/js/docs/src/content/docs/lex-reference/media/place-stream-media-createupload.md
@@ -24,11 +24,12 @@ Start a resumable upload of arbitrary media content. Returns a TUS upload URL an
**Schema Type:** `object`
-| Name | Type | Req'd | Description | Constraints |
-| ---------- | --------- | ----- | --------------------------------------------------------- | ----------- |
-| `size` | `integer` | ✅ | Total size of the upload in bytes. | |
-| `mimeType` | `string` | ✅ | MIME type of the content being uploaded (e.g. video/mp4). | |
-| `filename` | `string` | ❌ | Optional filename hint to attach as upload metadata. | |
+| Name | Type | Req'd | Description | Constraints |
+| ---------- | --------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
+| `size` | `integer` | ✅ | Total size of the upload in bytes. | |
+| `mimeType` | `string` | ✅ | MIME type of the content being uploaded (e.g. video/mp4). | |
+| `filename` | `string` | ❌ | Optional filename hint to attach as upload metadata. | |
+| `draftUri` | `string` | ❌ | Optional ats:// URI of a draft VOD created via place.stream.vod.createDraft. When supplied, the upload's processing fills that draft (rather than creating a new one), so the user can edit metadata while the upload runs and re-upload if it fails. The draft's origin_upload_id is set to this upload. | |
**Output:**
@@ -78,6 +79,10 @@ Start a resumable upload of arbitrary media content. Returns a TUS upload URL an
"filename": {
"type": "string",
"description": "Optional filename hint to attach as upload metadata."
+ },
+ "draftUri": {
+ "type": "string",
+ "description": "Optional ats:// URI of a draft VOD created via place.stream.vod.createDraft. When supplied, the upload's processing fills that draft (rather than creating a new one), so the user can edit metadata while the upload runs and re-upload if it fails. The draft's origin_upload_id is set to this upload."
}
}
}
diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json
index 793a063c..ec5324d5 100644
--- a/js/docs/src/content/docs/lex-reference/openapi.json
+++ b/js/docs/src/content/docs/lex-reference/openapi.json
@@ -82,6 +82,43 @@
]
}
},
+ "/xrpc/place.stream.vod.createDraft": {
+ "post": {
+ "summary": "Create an empty draft VOD in the 'processing' state. The draft is the durable anchor for an upload: the client creates it first, then passes its URI to place.stream.media.createUpload so the upload's processing fills this draft. Lets the user start editing metadata while the upload runs, and supports re-upload (a failed upload leaves the draft intact; a new createUpload with the same draftUri re-points it).",
+ "operationId": "place.stream.vod.createDraft",
+ "tags": ["place.stream.vod"],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The ats:// URI of the newly created draft record."
+ }
+ },
+ "required": ["uri"]
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ }
+ }
+ }
+ }
+ },
"/xrpc/place.stream.vod.deleteDraft": {
"post": {
"summary": "Discard a draft VOD. The processed content blob is not deleted (it may be referenced elsewhere). Only the draft record is removed.",
@@ -2640,6 +2677,10 @@
"filename": {
"type": "string",
"description": "Optional filename hint to attach as upload metadata."
+ },
+ "draftUri": {
+ "type": "string",
+ "description": "Optional ats:// URI of a draft VOD created via place.stream.vod.createDraft. When supplied, the upload's processing fills that draft (rather than creating a new one), so the user can edit metadata while the upload runs and re-upload if it fails. The draft's origin_upload_id is set to this upload."
}
},
"required": ["size", "mimeType"]
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md
new file mode 100644
index 00000000..c859d617
--- /dev/null
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md
@@ -0,0 +1,73 @@
+---
+title: place.stream.vod.createDraft
+description: Reference for the place.stream.vod.createDraft lexicon
+---
+**Lexicon Version:** 1
+
+## Definitions
+
+
+### `main`
+
+**Type:** `procedure`
+
+Create an empty draft VOD in the 'processing' state. The draft is the durable anchor for an upload: the client creates it first, then passes its URI to place.stream.media.createUpload so the upload's processing fills this draft. Lets the user start editing metadata while the upload runs, and supports re-upload (a failed upload leaves the draft intact; a new createUpload with the same draftUri re-points it).
+
+**Parameters:** _(None defined)_
+
+**Input:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+_(No properties defined)_
+**Output:**
+
+- **Encoding:** `application/json`
+- **Schema:**
+
+**Schema Type:** `object`
+
+| Name | Type | Req'd | Description | Constraints |
+|------|------|----------|-------------|-------------|
+| `uri` | `string` | ✅ | The ats:// URI of the newly created draft record. | |
+
+---
+
+## Lexicon Source
+```json
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.createDraft",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Create an empty draft VOD in the 'processing' state. The draft is the durable anchor for an upload: the client creates it first, then passes its URI to place.stream.media.createUpload so the upload's processing fills this draft. Lets the user start editing metadata while the upload runs, and supports re-upload (a failed upload leaves the draft intact; a new createUpload with the same draftUri re-points it).",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": [
+ "uri"
+ ],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The ats:// URI of the newly created draft record."
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
diff --git a/lexicons/place/stream/media/createUpload.json b/lexicons/place/stream/media/createUpload.json
index 52b44f9a..53227998 100644
--- a/lexicons/place/stream/media/createUpload.json
+++ b/lexicons/place/stream/media/createUpload.json
@@ -22,6 +22,10 @@
"filename": {
"type": "string",
"description": "Optional filename hint to attach as upload metadata."
+ },
+ "draftUri": {
+ "type": "string",
+ "description": "Optional ats:// URI of a draft VOD created via place.stream.vod.createDraft. When supplied, the upload's processing fills that draft (rather than creating a new one), so the user can edit metadata while the upload runs and re-upload if it fails. The draft's origin_upload_id is set to this upload."
}
}
}
diff --git a/lexicons/place/stream/vod/createDraft.json b/lexicons/place/stream/vod/createDraft.json
new file mode 100644
index 00000000..fe2794c2
--- /dev/null
+++ b/lexicons/place/stream/vod/createDraft.json
@@ -0,0 +1,30 @@
+{
+ "lexicon": 1,
+ "id": "place.stream.vod.createDraft",
+ "defs": {
+ "main": {
+ "type": "procedure",
+ "description": "Create an empty draft VOD in the 'processing' state. The draft is the durable anchor for an upload: the client creates it first, then passes its URI to place.stream.media.createUpload so the upload's processing fills this draft. Lets the user start editing metadata while the upload runs, and supports re-upload (a failed upload leaves the draft intact; a new createUpload with the same draftUri re-points it).",
+ "input": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "properties": {}
+ }
+ },
+ "output": {
+ "encoding": "application/json",
+ "schema": {
+ "type": "object",
+ "required": ["uri"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "description": "The ats:// URI of the newly created draft record."
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/spxrpc/place_stream_media.go b/pkg/spxrpc/place_stream_media.go
index 40b10684..567f31f8 100644
--- a/pkg/spxrpc/place_stream_media.go
+++ b/pkg/spxrpc/place_stream_media.go
@@ -8,6 +8,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/streamplace/oatproxy/pkg/oatproxy"
+ "stream.place/streamplace/pkg/log"
placestream "stream.place/streamplace/pkg/streamplace"
)
@@ -52,6 +53,19 @@ func (s *Server) handlePlaceStreamMediaCreateUpload(ctx context.Context, body *p
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
+ // If the client created a draft up front (place.stream.vod.createDraft),
+ // associate this upload with it so processing fills that draft rather than
+ // creating a new one. This lets the user edit metadata while the upload
+ // runs and supports re-upload (a new createUpload with the same draftUri
+ // re-points the draft at the new upload).
+ if body.DraftUri != nil && *body.DraftUri != "" {
+ if err := s.statefulDB.SetDraftOriginUpload(ctx, *body.DraftUri, session.DID, res.UploadID); err != nil {
+ // A missing/stale draft isn't fatal: the upload proceeds and the
+ // TUS-completion path will create a fresh draft as before.
+ log.Warn(ctx, "createUpload: failed to associate draft with upload", "draftUri", *body.DraftUri, "uploadId", res.UploadID, "error", err)
+ }
+ }
+
return &placestream.MediaCreateUpload_Output{
UploadId: res.UploadID,
UploadUrl: res.UploadURL,
diff --git a/pkg/spxrpc/place_stream_vod_drafts.go b/pkg/spxrpc/place_stream_vod_drafts.go
index 2d55b725..cffe0662 100644
--- a/pkg/spxrpc/place_stream_vod_drafts.go
+++ b/pkg/spxrpc/place_stream_vod_drafts.go
@@ -161,6 +161,34 @@ func (s *Server) handlePlaceStreamVodDeleteDraft(ctx context.Context, body *plac
return &placestream.VodDeleteDraft_Output{}, nil
}
+// handlePlaceStreamVodCreateDraft creates an empty 'processing' draft VOD — the
+// durable anchor for an upload. The client creates it first, then passes the
+// returned URI to place.stream.media.createUpload so the upload's processing
+// fills this draft. Lets the user edit metadata while the upload runs and
+// supports re-upload (a failed upload leaves the draft intact).
+func (s *Server) handlePlaceStreamVodCreateDraft(ctx context.Context, body *placestream.VodCreateDraft_Input) (*placestream.VodCreateDraft_Output, error) {
+ session, _ := oatproxy.GetOAuthSession(ctx)
+ if session == nil {
+ return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session required")
+ }
+ // A banned account can't create VOD drafts.
+ if banned, err := s.accountBanned(session.DID); err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ } else if banned {
+ return nil, echo.NewHTTPError(http.StatusForbidden, "account is not permitted to publish videos")
+ }
+ dv, err := s.statefulDB.CreateDraft(ctx, session.DID, "", &placestream.VodDraftVideo{
+ LexiconTypeID: "place.stream.vod.draftVideo",
+ Title: "Untitled",
+ Status: "processing",
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ })
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ return &placestream.VodCreateDraft_Output{Uri: dv.URI}, nil
+}
+
func (s *Server) handlePlaceStreamVodPublishDraft(ctx context.Context, body *placestream.VodPublishDraft_Input) (*placestream.VodPublishDraft_Output, error) {
session, _ := oatproxy.GetOAuthSession(ctx)
if session == nil {
diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go
index 80f0fcf7..b2181b1c 100644
--- a/pkg/spxrpc/stubs.go
+++ b/pkg/spxrpc/stubs.go
@@ -338,6 +338,7 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error {
e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks)
e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook)
e.POST("/xrpc/place.stream.server.upsertStorage", s.HandlePlaceStreamServerUpsertStorage)
+ e.POST("/xrpc/place.stream.vod.createDraft", s.HandlePlaceStreamVodCreateDraft)
e.POST("/xrpc/place.stream.vod.deleteDraft", s.HandlePlaceStreamVodDeleteDraft)
e.GET("/xrpc/place.stream.vod.getComments", s.HandlePlaceStreamVodGetComments)
e.GET("/xrpc/place.stream.vod.getDraft", s.HandlePlaceStreamVodGetDraft)
@@ -1356,6 +1357,24 @@ func (s *Server) HandlePlaceStreamServerUpsertStorage(c echo.Context) error {
return c.JSON(200, out)
}
+func (s *Server) HandlePlaceStreamVodCreateDraft(c echo.Context) error {
+ ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamVodCreateDraft")
+ defer span.End()
+
+ var body placestream.VodCreateDraft_Input
+ if err := c.Bind(&body); err != nil {
+ return err
+ }
+ var out *placestream.VodCreateDraft_Output
+ var handleErr error
+ // func (s *Server) handlePlaceStreamVodCreateDraft(ctx context.Context,body *placestream.VodCreateDraft_Input) (*placestream.VodCreateDraft_Output, error)
+ out, handleErr = s.handlePlaceStreamVodCreateDraft(ctx, &body)
+ if handleErr != nil {
+ return handleErr
+ }
+ return c.JSON(200, out)
+}
+
func (s *Server) HandlePlaceStreamVodDeleteDraft(c echo.Context) error {
ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamVodDeleteDraft")
defer span.End()
diff --git a/pkg/statedb/draft_video.go b/pkg/statedb/draft_video.go
index 8ef31cba..0729c95c 100644
--- a/pkg/statedb/draft_video.go
+++ b/pkg/statedb/draft_video.go
@@ -111,6 +111,25 @@ func (state *StatefulDB) CreateDraft(ctx context.Context, did, originUploadID st
return dv, nil
}
+// SetDraftOriginUpload re-points a draft at a (new) upload ID, so the upload's
+// processing fills this draft. Used by createUpload when the client supplies a
+// draftUri: the draft was created up front (place.stream.vod.createDraft), and
+// this associates it with the upload that will populate it. Re-upload just
+// calls this again with a new uploadID. Returns an error if the draft doesn't
+// exist or doesn't belong to did.
+func (state *StatefulDB) SetDraftOriginUpload(ctx context.Context, uri, did, uploadID string) error {
+ res := state.DB.WithContext(ctx).Model(&DraftVideo{}).
+ Where("uri = ? AND user_did = ?", uri, did).
+ Update("origin_upload_id", uploadID)
+ if res.Error != nil {
+ return fmt.Errorf("set draft origin upload: %w", res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return gorm.ErrRecordNotFound
+ }
+ return nil
+}
+
// GetDraft fetches a draft by its ats:// URI. Returns (nil, nil) if not found.
func (state *StatefulDB) GetDraft(ctx context.Context, uri string) (*DraftVideo, error) {
var dv DraftVideo
diff --git a/pkg/streamplace/mediacreateUpload.go b/pkg/streamplace/mediacreateUpload.go
index 7f0015b8..768900f2 100644
--- a/pkg/streamplace/mediacreateUpload.go
+++ b/pkg/streamplace/mediacreateUpload.go
@@ -12,6 +12,8 @@ import (
// MediaCreateUpload_Input is the input argument to a place.stream.media.createUpload call.
type MediaCreateUpload_Input struct {
+ // draftUri: Optional ats:// URI of a draft VOD created via place.stream.vod.createDraft. When supplied, the upload's processing fills that draft (rather than creating a new one), so the user can edit metadata while the upload runs and re-upload if it fails. The draft's origin_upload_id is set to this upload.
+ DraftUri *string `json:"draftUri,omitempty" cborgen:"draftUri,omitempty"`
// filename: Optional filename hint to attach as upload metadata.
Filename *string `json:"filename,omitempty" cborgen:"filename,omitempty"`
// mimeType: MIME type of the content being uploaded (e.g. video/mp4).
diff --git a/pkg/streamplace/vodcreateDraft.go b/pkg/streamplace/vodcreateDraft.go
new file mode 100644
index 00000000..3aa93756
--- /dev/null
+++ b/pkg/streamplace/vodcreateDraft.go
@@ -0,0 +1,31 @@
+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+// Lexicon schema: place.stream.vod.createDraft
+
+package streamplace
+
+import (
+ "context"
+
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+)
+
+// VodCreateDraft_Input is the input argument to a place.stream.vod.createDraft call.
+type VodCreateDraft_Input struct {
+}
+
+// VodCreateDraft_Output is the output of a place.stream.vod.createDraft call.
+type VodCreateDraft_Output struct {
+ // uri: The ats:// URI of the newly created draft record.
+ Uri string `json:"uri" cborgen:"uri"`
+}
+
+// VodCreateDraft calls the XRPC method "place.stream.vod.createDraft".
+func VodCreateDraft(ctx context.Context, c lexutil.LexClient, input *VodCreateDraft_Input) (*VodCreateDraft_Output, error) {
+ var out VodCreateDraft_Output
+ if err := c.LexDo(ctx, lexutil.Procedure, "application/json", "place.stream.vod.createDraft", nil, input, &out); err != nil {
+ return nil, err
+ }
+
+ return &out, nil
+}
diff --git a/pkg/upload/upload.go b/pkg/upload/upload.go
index 8b63e202..3021a46a 100644
--- a/pkg/upload/upload.go
+++ b/pkg/upload/upload.go
@@ -327,18 +327,23 @@ func (m *Manager) onComplete(ctx context.Context, ev tushandler.HookEvent) {
log.Error(ctx, "failed to enqueue vod-process task", "error", err)
}
- // Create a draft VOD in the 'processing' state. Unlike finalize (which
- // inherits livestream metadata), a plain upload starts with placeholder
- // metadata the user fills in from the Drafts tab once processing completes.
- // A failed create here is non-fatal: the upload still processes, it just
- // won't surface as a draft (the user re-publishes via publishVideo).
- if _, err := m.state.CreateDraft(ctx, row.RepoDID, row.ID, &streamplace.VodDraftVideo{
- LexiconTypeID: "place.stream.vod.draftVideo",
- Title: filenameOrDefault(row.Filename),
- Status: "processing",
- CreatedAt: time.Now().UTC().Format(time.RFC3339),
- }); err != nil {
- log.Error(ctx, "failed to create draft for upload", "error", err)
+ // Create a draft VOD in the 'processing' state, but only if the upload
+ // isn't already associated with a pre-created draft. The modern client
+ // flow creates the draft up front (place.stream.vod.createDraft) and passes
+ // its URI to createUpload, which re-points the draft's origin_upload_id at
+ // this upload — so a draft already exists for this row. Only fall back to
+ // creating one here for legacy clients / external callers that didn't
+ // supply a draftUri. A failed create is non-fatal: the upload still
+ // processes.
+ if existing, _ := m.state.GetDraftByUpload(ctx, row.ID); existing == nil {
+ if _, err := m.state.CreateDraft(ctx, row.RepoDID, row.ID, &streamplace.VodDraftVideo{
+ LexiconTypeID: "place.stream.vod.draftVideo",
+ Title: filenameOrDefault(row.Filename),
+ Status: "processing",
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ log.Error(ctx, "failed to create draft for upload", "error", err)
+ }
}
}
--
2.51.2
From f3d37761a7e8486bf7aa561a42897b7e5b7b5a51 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 26 Jun 2026 17:50:20 -0700
Subject: [PATCH 26/34] draft-vods: regenerate createDraft markdown doc
---
.../vod/place-stream-vod-createdraft.md | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md
index c859d617..dbd01989 100644
--- a/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md
+++ b/js/docs/src/content/docs/lex-reference/vod/place-stream-vod-createdraft.md
@@ -2,11 +2,13 @@
title: place.stream.vod.createDraft
description: Reference for the place.stream.vod.createDraft lexicon
---
+
**Lexicon Version:** 1
## Definitions
+
### `main`
**Type:** `procedure`
@@ -30,13 +32,14 @@ _(No properties defined)_
**Schema Type:** `object`
-| Name | Type | Req'd | Description | Constraints |
-|------|------|----------|-------------|-------------|
-| `uri` | `string` | ✅ | The ats:// URI of the newly created draft record. | |
+| Name | Type | Req'd | Description | Constraints |
+| ----- | -------- | ----- | ------------------------------------------------- | ----------- |
+| `uri` | `string` | ✅ | The ats:// URI of the newly created draft record. | |
---
## Lexicon Source
+
```json
{
"lexicon": 1,
@@ -56,9 +59,7 @@ _(No properties defined)_
"encoding": "application/json",
"schema": {
"type": "object",
- "required": [
- "uri"
- ],
+ "required": ["uri"],
"properties": {
"uri": {
"type": "string",
--
2.51.2
From 34418389432acc608a89d9d5295fde5ca0c32094 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 26 Jun 2026 17:55:03 -0700
Subject: [PATCH 27/34] draft-vods: create-draft-first upload flow in the UI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
startUpload now creates an empty draft up front (place.stream.vod.createDraft),
passes its draftUri to createUpload, and navigates to /upload/video/
immediately — so the user lands on the metadata editor while the TUS upload
runs in the background. The draft is the durable anchor: a failed upload
leaves it intact, so re-upload against the same draft is possible.
The UploadVideo editor now polls getDraft every 3s while the draft is still
'processing', so the processing → ready (or error) transition is reflected
without a manual reload. Only the status (and source/durationMs once ready)
is refreshed — never the editable metadata the user may be mid-edit on.
tsc + prettier clean.
Co-authored-by: Claude Opus 4.8
---
js/app/src/screens/upload.tsx | 67 +++++++++++++++++++++++++++++------
1 file changed, 56 insertions(+), 11 deletions(-)
diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx
index dc2c1956..b6813f77 100644
--- a/js/app/src/screens/upload.tsx
+++ b/js/app/src/screens/upload.tsx
@@ -727,14 +727,30 @@ export default function UploadScreen() {
const mimeType = file.type.startsWith("video/") ? file.type : "video/mp4";
setPhase({ kind: "creating" });
try {
+ // Create the empty draft first — it's the durable anchor for this
+ // upload. The user lands on the draft editor immediately and can edit
+ // metadata while the upload runs; a failed upload leaves the draft
+ // intact so they can re-upload against the same draft.
+ const draftRes = await agent.place.stream.vod.createDraft({});
+ if (!draftRes.success) throw new Error("createDraft failed");
+ const draftUri = draftRes.data.uri;
+ const tid = tidFromUri(draftUri);
+
const res = await agent.place.stream.media.createUpload({
size: file.size,
mimeType,
filename: file.name,
+ draftUri,
});
if (!res.success) throw new Error("createUpload failed");
const { uploadUrl, uploadToken } = res.data;
+ // Navigate to the draft editor now — the upload continues in the
+ // background and fills this draft when it finishes processing.
+ navigation.navigate("UploadVideo" as any, { tid });
+
+ // The TUS upload runs to completion here; the draft (already navigated
+ // to) will flip to 'ready' server-side when processing finishes.
await new Promise((resolve, reject) => {
let retried = false;
const params: tus.UploadOptions = {
@@ -754,12 +770,9 @@ export default function UploadScreen() {
}
},
onProgress(bytesSent, bytesTotal) {
- setPhase({
- kind: "uploading",
- pct: bytesTotal > 0 ? (bytesSent / bytesTotal) * 100 : 0,
- bytesSent,
- bytesTotal,
- });
+ // Progress isn't shown on the bare upload screen anymore (we've
+ // navigated away to the editor); the editor surfaces the draft's
+ // processing status instead.
},
onSuccess: () => resolve(),
};
@@ -772,10 +785,9 @@ export default function UploadScreen() {
});
uploadRef.current = null;
- // The server creates a draft VOD automatically once the TUS upload
- // finishes processing. The client no longer polls getUploadStatus or
- // calls publishVideo — the user publishes from the Drafts tab instead.
- setPhase({ kind: "done" });
+ // No phase change here: we've already navigated to the draft editor,
+ // which polls/reloads the draft and reflects the 'ready' state when
+ // processing completes.
} catch (err) {
uploadRef.current = null;
setPhase({
@@ -783,7 +795,7 @@ export default function UploadScreen() {
message: err instanceof Error ? err.message : String(err),
});
}
- }, [agent, file]);
+ }, [agent, file, navigation]);
const cancelUpload = useCallback(() => {
if (pollRef.current) clearTimeout(pollRef.current);
@@ -1237,6 +1249,39 @@ export function UploadVideoScreen({ route }: { route: any }) {
};
}, [agent, tid]);
+ // While the draft is still processing, poll so the editor reflects the
+ // processing → ready (or error) transition without a manual reload. Only
+ // refreshes the status (and source/durationMs once ready), never the
+ // editable metadata the user may be mid-edit on.
+ useEffect(() => {
+ if (
+ mode !== "draft" ||
+ draftStatus !== "processing" ||
+ !agent ||
+ !agent.did
+ ) {
+ return;
+ }
+ let cancelled = false;
+ const poll = async () => {
+ try {
+ const res = await agent.place.stream.vod.getDraft({
+ uri: draftUri(agent.did!, tid),
+ });
+ if (cancelled) return;
+ const rec = res.data.draft.record as PlaceStreamVodDraftVideo.Main;
+ setDraftStatus(rec.status);
+ } catch {
+ // Transient fetch failures are fine; the next tick retries.
+ }
+ };
+ const interval = setInterval(poll, 3000);
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, [mode, draftStatus, agent, tid]);
+
const handleSave = useCallback(async () => {
if (!agent || !agent.did) return;
setUpdating(true);
--
2.51.2
From e8a011cb82c4ab2faccfd9cae388cac1de8feb00 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Fri, 26 Jun 2026 18:21:44 -0700
Subject: [PATCH 28/34] draft-vods: defer media.track publication to
publishDraft time
place.stream.media.track records were published at processing time (in
publishRecords), which leaked half-published content: the tracks went live
immediately while the video record wasn't created until the user published the
draft. With drafts, that gap can be long. Defer track creation to publishDraft.
- publishRecords stops calling publishTrack. It still publishes
place.stream.media.origin (server-repo blob availability attestation) and
now stores the track-publishing inputs on the Upload row: signing_key,
probe_json (video/audio codec/dims/fps/rate/channels), and blob_size.
SetUploadProcessed signature updated accordingly.
- markDraftReadyFromUpload: the draft reaches 'ready' with source=nil (no
track refs exist yet) and durationMs set (known at processing time).
- PublishDraft publishes the tracks at publish time via the new
publishTracksFromUpload: reads the probe+signingKey+size+CID from the tied
Upload row, calls publishTrack per A/V stream, and builds the video's
source from the fresh strongRefs. Falls back to a carried-over source if
the draft has no tied upload (legacy).
- Upload row gains SigningKey, ProbeJSON, BlobSize columns. TrackURIs is
vestigial (left for the legacy publishVideo path).
Tests updated: the lifecycle test now asserts source is nil at ready time and
the Upload row carries the deferred inputs. build + tests + lint clean.
Co-authored-by: Claude Opus 4.8
---
pkg/statedb/draft_video.go | 15 ++-
pkg/statedb/queue_processor_draft_test.go | 20 ++--
pkg/statedb/upload.go | 34 +++++-
pkg/vod/publish.go | 127 +++++++++++++++-------
pkg/vod/publish_draft.go | 84 ++++++++++++--
5 files changed, 214 insertions(+), 66 deletions(-)
diff --git a/pkg/statedb/draft_video.go b/pkg/statedb/draft_video.go
index 0729c95c..861185fc 100644
--- a/pkg/statedb/draft_video.go
+++ b/pkg/statedb/draft_video.go
@@ -236,11 +236,13 @@ func (state *StatefulDB) SetDraftReady(ctx context.Context, originUploadID strin
}
// markDraftReadyFromUpload re-reads a (now-finished) Upload row and flips its
-// tied draft to 'ready', filling source/durationMs from the row and content_cid
-// for later thumbnail generation. Called by the queue processors after the VOD
-// processor / livestream finalizer returns, since those call SetUploadProcessed
-// internally and return only a cid — the processor's results land on the Upload
-// row, which we re-read here. A missing draft is a no-op.
+// tied draft to 'ready', filling durationMs + content_cid. The draft's source
+// is left empty: track records are deferred to publishDraft time, so there
+// are no track refs to populate it with at ready time. Called by the queue
+// processors after the VOD processor / livestream finalizer returns, since
+// those call SetUploadProcessed internally and return only a cid — the
+// processor's results land on the Upload row, which we re-read here. A missing
+// draft is a no-op.
func (state *StatefulDB) markDraftReadyFromUpload(ctx context.Context, uploadID string) error {
upload, err := state.GetUpload(ctx, uploadID)
if err != nil {
@@ -249,6 +251,9 @@ func (state *StatefulDB) markDraftReadyFromUpload(ctx context.Context, uploadID
if upload == nil {
return nil
}
+ // source stays nil until PublishDraft publishes the tracks; durationMs is
+ // known at processing time. (draftSourceFromTrackURIs returns nil for the
+ // now-empty TrackURIs, so the legacy path is unchanged.)
source, err := draftSourceFromTrackURIs(upload.TrackURIs)
if err != nil {
return err
diff --git a/pkg/statedb/queue_processor_draft_test.go b/pkg/statedb/queue_processor_draft_test.go
index 7df6f15d..4bc6f203 100644
--- a/pkg/statedb/queue_processor_draft_test.go
+++ b/pkg/statedb/queue_processor_draft_test.go
@@ -36,9 +36,11 @@ func TestDraftLifecycleThroughVODProcessor(t *testing.T) {
// Fake processor: simulates vod.ProcessVOD's internal SetUploadProcessed
// (writes the finished fields onto the Upload row) then returns a cid.
- trackURIs := `[{"uri":"at://did:plc:trackhost/place.stream.media.track/t1","cid":"bafytrack1"}]`
+ // Tracks are deferred to publishDraft time now, so SetUploadProcessed
+ // stores the probe + signingKey (not track refs).
+ probeJSON := `{"durationMs":98765,"video":{"codec":"h264","width":1280,"height":720,"fpsNum":30,"fpsDen":1}}`
state.SetVODProcessor(func(ctx context.Context, task VODProcessTask) (string, error) {
- require.NoError(t, state.SetUploadProcessed(ctx, task.UploadID, trackURIs, 98765, "muxlcid-lifecycle"))
+ require.NoError(t, state.SetUploadProcessed(ctx, task.UploadID, 98765, "muxlcid-lifecycle", "did:key:signing", probeJSON, 123456))
return "muxlcid-lifecycle", nil
})
@@ -50,7 +52,9 @@ func TestDraftLifecycleThroughVODProcessor(t *testing.T) {
})}
_ = state.processVODProcessTask(ctx, task)
- // The draft must now be 'ready' and carry the upload's values.
+ // The draft must now be 'ready'. durationMs + content_cid are filled
+ // from the upload row; source is nil (tracks are published at
+ // publishDraft time, not at ready time).
dv, err := state.GetDraftByUpload(ctx, "up-lifecycle")
require.NoError(t, err)
require.NotNil(t, dv)
@@ -60,10 +64,12 @@ func TestDraftLifecycleThroughVODProcessor(t *testing.T) {
require.Equal(t, "ready", rec.Status)
require.NotNil(t, rec.DurationMs)
require.Equal(t, int64(98765), *rec.DurationMs)
- require.NotNil(t, rec.Source)
- require.NotNil(t, rec.Source.MediaDefs_SourceTracks)
- require.Len(t, rec.Source.MediaDefs_SourceTracks.Tracks, 1)
- require.Equal(t, "at://did:plc:trackhost/place.stream.media.track/t1", rec.Source.MediaDefs_SourceTracks.Tracks[0].Uri)
+ require.Nil(t, rec.Source, "source must be nil at ready time (tracks deferred to publish)")
+ // The Upload row carries the deferred track-publishing inputs.
+ up, err := state.GetUpload(ctx, "up-lifecycle")
+ require.NoError(t, err)
+ require.Equal(t, "did:key:signing", up.SigningKey)
+ require.Contains(t, up.ProbeJSON, "h264")
})
}
diff --git a/pkg/statedb/upload.go b/pkg/statedb/upload.go
index 0c45ac48..921aab9a 100644
--- a/pkg/statedb/upload.go
+++ b/pkg/statedb/upload.go
@@ -31,9 +31,11 @@ type Upload struct {
ProcessingStatus string `gorm:"column:processing_status"`
ProcessingError string `gorm:"column:processing_error"`
ProcessingProgress int `gorm:"column:processing_progress;default:0"`
- // TrackURIs is a JSON array of {"uri":"at://...","cid":"..."} objects
- // populated once the track records are published and the video is ready
- // for the client to create a place.stream.video record.
+ // TrackURIs is a JSON array of {"uri":"at://...","cid":"..."} objects.
+ // Vestigial since track publication was deferred to publishDraft time:
+ // new uploads leave this empty and PublishDraft publishes the tracks on
+ // demand. Retained so existing rows / the legacy publishVideo path still
+ // read it.
TrackURIs string `gorm:"column:track_uris"`
DurationMS int64 `gorm:"column:duration_ms"`
// ContentCID is the BDASL CID of the processed fMP4 blob. Stored so the
@@ -41,6 +43,20 @@ type Upload struct {
// publishVideo's thumbnail generation) without re-deriving it from the
// published track records.
ContentCID string `gorm:"column:content_cid"`
+ // SigningKey is the did:key whose ephemeral private half C2PA-signed the
+ // segments. Stored at processing time so PublishDraft can publish the
+ // place.stream.media.track records (which carry it) at publish time.
+ SigningKey string `gorm:"column:signing_key"`
+ // ProbeJSON is the gstreamer probe metadata (video/audio codec, dims,
+ // fps, rate, channels) serialized as JSON. Stored at processing time so
+ // PublishDraft can publish the track records (deferred from processing)
+ // without re-probing the blob.
+ ProbeJSON string `gorm:"column:probe_json"`
+ // BlobSize is the byte size of the processed MUXL content blob (distinct
+ // from Size, the raw upload size). Stored at processing time so
+ // PublishDraft can populate the track records' size field without
+ // re-statting the blob.
+ BlobSize int64 `gorm:"column:blob_size"`
}
func (Upload) TableName() string {
@@ -88,15 +104,23 @@ func (state *StatefulDB) SetUploadProgress(ctx context.Context, id string, progr
Update("processing_progress", progress).Error
}
-func (state *StatefulDB) SetUploadProcessed(ctx context.Context, id string, trackURIsJSON string, durationMS int64, contentCID string) error {
+// SetUploadProcessed marks an upload done and stores the processing results
+// the publishDraft path needs: duration, the content blob's CID, the C2PA
+// signing key, and the gstreamer probe metadata (JSON). Track records are
+// NOT published here — they're deferred to publishDraft time so half-
+// published tracks don't go live before the video record. trackURIs is left
+// empty (vestigial; the legacy publishVideo path may still set it).
+func (state *StatefulDB) SetUploadProcessed(ctx context.Context, id string, durationMS int64, contentCID, signingKey, probeJSON string, blobSize int64) error {
return state.DB.WithContext(ctx).Model(&Upload{}).
Where("id = ?", id).
Updates(map[string]any{
"processing_status": "done",
"processing_progress": 100,
- "track_uris": trackURIsJSON,
"duration_ms": durationMS,
"content_cid": contentCID,
+ "signing_key": signingKey,
+ "probe_json": probeJSON,
+ "blob_size": blobSize,
}).Error
}
diff --git a/pkg/vod/publish.go b/pkg/vod/publish.go
index 57cc7090..b24d8663 100644
--- a/pkg/vod/publish.go
+++ b/pkg/vod/publish.go
@@ -48,18 +48,19 @@ type publishParams struct {
signingKey string
}
-// publishRecords does the post-processing record publish:
+// publishRecords does the post-processing record publish. With tracks
+// deferred to publishDraft time, this now:
//
-// 1. place.stream.media.origin in the SERVER's repo (we attest that
-// this blob is fetchable from us). Idempotent: rkey is the CID.
-// 2. place.stream.media.track in the USER's repo, one per A/V track,
-// via the user's stored OAuth session.
-// 3. Stores the resulting track URIs + duration on the Upload row so
-// the client can poll getUploadStatus and create the
-// place.stream.video record itself (with full metadata) via Publish.
+// 1. place.stream.media.origin in the SERVER's repo (we attest that this
+// blob is fetchable from us). Idempotent: rkey is the CID.
+// 2. Stores the probe metadata + signing key + duration + CID on the Upload
+// row so publishDraft can publish the place.stream.media.track records
+// (and build the video's source) at publish time.
//
-// The video record is intentionally NOT created here — the client
-// controls when it becomes visible and supplies the metadata.
+// Track records are intentionally NOT published here: with drafts, the video
+// record isn't visible until the user publishes, so publishing tracks at
+// processing time would leak half-published content. publishDraft publishes
+// them when the video record is created.
func publishRecords(ctx context.Context, p publishParams) error {
ctx, span := vodTracer.Start(ctx, "vod.publishRecords", trace.WithAttributes(
attribute.String("cid", p.cid),
@@ -73,48 +74,92 @@ func publishRecords(ctx context.Context, p publishParams) error {
return fmt.Errorf("publish origin: %w", err)
}
- client, err := getUserXRPCClient(ctx, p.state, p.in.RepoDID)
+ // Serialize the probe so publishDraft can publish the track records later
+ // without re-probing the blob.
+ probeJSON, err := marshalProbe(p.probe)
if err != nil {
span.RecordError(err)
- span.SetStatus(codes.Error, "get_client")
- return fmt.Errorf("get user xrpc client: %w", err)
+ span.SetStatus(codes.Error, "marshal_probe")
+ return fmt.Errorf("marshal probe: %w", err)
}
- var trackRefs []trackRefJSON
- if p.probe.Video != nil {
- ref, err := publishTrack(ctx, client, p.in.RepoDID, p.cid, p.size, p.probe.DurationMS, "1", "video", p.signingKey, p.probe.Video, nil)
- if err != nil {
- span.RecordError(err)
- span.SetStatus(codes.Error, "video_track")
- return fmt.Errorf("publish video track: %w", err)
+ if err := p.state.SetUploadProcessed(ctx, p.in.UploadID, p.probe.DurationMS, p.cid, p.signingKey, probeJSON, p.size); err != nil {
+ span.RecordError(err)
+ span.SetStatus(codes.Error, "store_results")
+ return fmt.Errorf("store processing results: %w", err)
+ }
+
+ span.SetStatus(codes.Ok, "")
+ log.Log(ctx, "stored processing results (tracks deferred to publish)",
+ "uploadId", p.in.UploadID, "cid", p.cid, "duration_ms", p.probe.DurationMS)
+ return nil
+}
+
+// probeJSONShape mirrors media.VODResult's track fields, for serialization to
+// the Upload row's probe_json column. Only the fields publishTrack consumes.
+type probeJSONShape struct {
+ DurationMS int64 `json:"durationMs"`
+ Video *videoProbeJSON `json:"video,omitempty"`
+ Audio *audioProbeJSON `json:"audio,omitempty"`
+}
+type videoProbeJSON struct {
+ Codec string `json:"codec"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ FPSNum int `json:"fpsNum"`
+ FPSDen int `json:"fpsDen"`
+}
+type audioProbeJSON struct {
+ Codec string `json:"codec"`
+ Rate int `json:"rate"`
+ Channels int `json:"channels"`
+ MPEGVersion int `json:"mpegVersion"`
+}
+
+func marshalProbe(p media.VODResult) (string, error) {
+ out := probeJSONShape{DurationMS: p.DurationMS}
+ if p.Video != nil {
+ out.Video = &videoProbeJSON{
+ Codec: p.Video.Codec, Width: p.Video.Width, Height: p.Video.Height,
+ FPSNum: p.Video.FPSNum, FPSDen: p.Video.FPSDen,
}
- trackRefs = append(trackRefs, trackRefJSON{URI: ref.Uri, CID: ref.Cid})
}
- if p.probe.Audio != nil {
- ref, err := publishTrack(ctx, client, p.in.RepoDID, p.cid, p.size, p.probe.DurationMS, "2", "audio", p.signingKey, nil, p.probe.Audio)
- if err != nil {
- span.RecordError(err)
- span.SetStatus(codes.Error, "audio_track")
- return fmt.Errorf("publish audio track: %w", err)
+ if p.Audio != nil {
+ out.Audio = &audioProbeJSON{
+ Codec: p.Audio.Codec, Rate: p.Audio.Rate, Channels: p.Audio.Channels,
+ MPEGVersion: p.Audio.MPEGVersion,
}
- trackRefs = append(trackRefs, trackRefJSON{URI: ref.Uri, CID: ref.Cid})
}
-
- trackURIsJSON, err := json.Marshal(trackRefs)
+ b, err := json.Marshal(out)
if err != nil {
- span.RecordError(err)
- span.SetStatus(codes.Error, "marshal_tracks")
- return fmt.Errorf("marshal track refs: %w", err)
- }
- if err := p.state.SetUploadProcessed(ctx, p.in.UploadID, string(trackURIsJSON), p.probe.DurationMS, p.cid); err != nil {
- span.RecordError(err)
- span.SetStatus(codes.Error, "store_tracks")
- return fmt.Errorf("store track refs: %w", err)
+ return "", err
}
+ return string(b), nil
+}
- span.SetAttributes(attribute.Int("track_count", len(trackRefs)))
- span.SetStatus(codes.Ok, "")
- return nil
+// unmarshalProbe reverses marshalProbe.
+func unmarshalProbe(s string) (media.VODResult, error) {
+ if s == "" {
+ return media.VODResult{}, nil
+ }
+ var pjs probeJSONShape
+ if err := json.Unmarshal([]byte(s), &pjs); err != nil {
+ return media.VODResult{}, fmt.Errorf("unmarshal probe: %w", err)
+ }
+ res := media.VODResult{DurationMS: pjs.DurationMS}
+ if pjs.Video != nil {
+ res.Video = &media.VODVideoTrack{
+ Codec: pjs.Video.Codec, Width: pjs.Video.Width, Height: pjs.Video.Height,
+ FPSNum: pjs.Video.FPSNum, FPSDen: pjs.Video.FPSDen,
+ }
+ }
+ if pjs.Audio != nil {
+ res.Audio = &media.VODAudioTrack{
+ Codec: pjs.Audio.Codec, Rate: pjs.Audio.Rate, Channels: pjs.Audio.Channels,
+ MPEGVersion: pjs.Audio.MPEGVersion,
+ }
+ }
+ return res, nil
}
// publishOrigin attests that this server has the blob with the given
diff --git a/pkg/vod/publish_draft.go b/pkg/vod/publish_draft.go
index 722422d1..04b8ea81 100644
--- a/pkg/vod/publish_draft.go
+++ b/pkg/vod/publish_draft.go
@@ -84,8 +84,32 @@ func PublishDraft(ctx context.Context, state *statedb.StatefulDB, store blob.Sto
video.ContentRights = rec.ContentRights
video.Thumb = rec.Thumb
- // Carry over the source union (the published track refs).
- if rec.Source != nil && rec.Source.MediaDefs_SourceTracks != nil {
+ // Publish the place.stream.media.track records now (deferred from
+ // processing time so they don't leak before the video record). Read the
+ // probe + signing key from the tied Upload row, publish one track per
+ // A/V stream, and build the video's source from the fresh strongRefs.
+ // If there's no Upload row (e.g. a draft whose upload predates this
+ // change, or a draft created outside the upload flow), fall back to
+ // carrying over any source the draft already carries.
+ client, err := getUserXRPCClient(ctx, state, did)
+ if err != nil {
+ span.RecordError(err)
+ span.SetStatus(codes.Error, "get_client")
+ return "", "", fmt.Errorf("get user xrpc client: %w", err)
+ }
+ sourceTracks, terr := publishTracksFromUpload(ctx, state, client, did, dv)
+ if terr != nil {
+ span.RecordError(terr)
+ span.SetStatus(codes.Error, "publish_tracks")
+ return "", "", fmt.Errorf("publish tracks: %w", terr)
+ }
+ if sourceTracks != nil {
+ video.Source = &streamplace.Video_Source{
+ MediaDefs_SourceTracks: sourceTracks,
+ }
+ } else if rec.Source != nil && rec.Source.MediaDefs_SourceTracks != nil {
+ // Legacy fallback: a draft that already carries source (e.g. published
+ // via the old at-ready-time path). Carry it over.
video.Source = &streamplace.Video_Source{
MediaDefs_SourceTracks: rec.Source.MediaDefs_SourceTracks,
}
@@ -95,12 +119,6 @@ func PublishDraft(ctx context.Context, state *statedb.StatefulDB, store blob.Sto
}
}
- client, err := getUserXRPCClient(ctx, state, did)
- if err != nil {
- span.RecordError(err)
- return "", "", fmt.Errorf("get user xrpc client: %w", err)
- }
-
// Reuse the draft's tid as the published video's rkey. The draft is
// deleted after a successful publish, so there's no collision, and sharing
// the tid lets a single /upload/video/ route render either a draft
@@ -172,6 +190,56 @@ func draftTID(atsURI string) (string, error) {
return atsURI[idx+1:], nil
}
+// publishTracksFromUpload publishes the place.stream.media.track records for a
+// draft's tied upload, deferred from processing time to publish time. It reads
+// the probe metadata + signing key + blob size + CID from the Upload row and
+// calls publishTrack for each A/V stream, returning the fresh strongRefs to use
+// as the video record's source. Returns (nil, nil) if the draft has no tied
+// upload (so the caller can fall back to a carried-over source).
+func publishTracksFromUpload(ctx context.Context, state *statedb.StatefulDB, client XRPCClient, did string, dv *statedb.DraftVideo) (*streamplace.MediaDefs_SourceTracks, error) {
+ if dv.OriginUploadID == "" {
+ return nil, nil
+ }
+ upload, err := state.GetUpload(ctx, dv.OriginUploadID)
+ if err != nil {
+ return nil, fmt.Errorf("get upload: %w", err)
+ }
+ if upload == nil {
+ return nil, nil
+ }
+ probe, err := unmarshalProbe(upload.ProbeJSON)
+ if err != nil {
+ return nil, err
+ }
+ if upload.ContentCID == "" {
+ return nil, fmt.Errorf("upload %s has no content_cid", upload.ID)
+ }
+ var tracks []*comatproto.RepoStrongRef
+ if probe.Video != nil {
+ ref, err := publishTrack(ctx, client, did, upload.ContentCID, upload.BlobSize, probe.DurationMS, "1", "video", upload.SigningKey, probe.Video, nil)
+ if err != nil {
+ return nil, fmt.Errorf("publish video track: %w", err)
+ }
+ tracks = append(tracks, ref)
+ }
+ if probe.Audio != nil {
+ ref, err := publishTrack(ctx, client, did, upload.ContentCID, upload.BlobSize, probe.DurationMS, "2", "audio", upload.SigningKey, nil, probe.Audio)
+ if err != nil {
+ return nil, fmt.Errorf("publish audio track: %w", err)
+ }
+ tracks = append(tracks, ref)
+ }
+ if len(tracks) == 0 {
+ return nil, nil
+ }
+ log.Log(ctx, "published media.track records (deferred to publish)",
+ "uploadId", upload.ID, "cid", upload.ContentCID, "tracks", len(tracks))
+ return &streamplace.MediaDefs_SourceTracks{
+ LexiconTypeID: "place.stream.media.defs#sourceTracks",
+ Tracks: tracks,
+ }, nil
+}
+
func derefInt64(p *int64) int64 {
if p == nil {
return 0
--
2.51.2
From 836a18a4e2ad3291a1814361e1bfb307c3bd4ba4 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Sun, 28 Jun 2026 18:18:35 -0700
Subject: [PATCH 29/34] upload: fix button widths
---
js/app/src/screens/upload.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx
index b6813f77..c7d56cdc 100644
--- a/js/app/src/screens/upload.tsx
+++ b/js/app/src/screens/upload.tsx
@@ -1746,6 +1746,7 @@ export function UploadDraftsScreen() {
navigation.navigate("UploadVideo" as any, {
@@ -1757,12 +1758,14 @@ export function UploadDraftsScreen() {
handlePublishDraft(draft.uri)}
>
Publish
handleDeleteDraft(draft.uri)}
>
--
2.51.2
From f126cad2cf2ed33e2f235539e3bc51cffcbe6c9a Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 30 Jun 2026 12:38:53 -0700
Subject: [PATCH 30/34] go mod tidy, again, surely it's right this time
---
go.mod | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index e9ce0325..a83ed7e7 100644
--- a/go.mod
+++ b/go.mod
@@ -58,6 +58,7 @@ require (
github.com/pion/webrtc/v4 v4.0.11
github.com/piprate/json-gold v0.5.0
github.com/prometheus/client_golang v1.23.2
+ github.com/prometheus/client_model v0.6.2
github.com/rivo/uniseg v0.4.7
github.com/rs/cors v1.11.1
github.com/samber/slog-http v1.4.0
@@ -448,7 +449,6 @@ require (
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
- github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/prometheus/statsd_exporter v0.27.1 // indirect
--
2.51.2
From 783b1de10c6da912ad40e91f6d5f479449ca89f7 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 30 Jun 2026 13:30:40 -0700
Subject: [PATCH 31/34] live-vod: gate livestream recording on VOD beta +
opt-in setting
Only record a livestream into a VOD when both hold:
- the streamer is in the VOD beta (an indexed place.stream.beta.invite
with feature=vod from the trusted issuer), mirroring the upload gate;
- the streamer opted in via a new livestreamRecording server setting.
shouldRecordLivestream runs once at stream start (like debugRecording) and
fails closed on lookup errors; when ineligible the uploader is never created,
so the existing s3Upload/s3Cutover/s3Close no-op on nil.
Adds the livestreamRecording boolean to place.stream.server.settings (Go
types + cbor hand-edited to match the generator) and a VOD-beta-only toggle
in privacy settings. createServerSettingsRecord now merges a partial patch so
toggling one flag no longer clobbers the others.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../settings/privacy-category-settings.tsx | 29 ++++++--
js/app/store/slices/blueskySlice.ts | 13 +++-
js/components/locales/en-US/settings.ftl | 2 +
.../server/place-stream-server-settings.md | 11 +++-
lexicons/place/stream/server/settings.json | 4 ++
pkg/director/s3_upload.go | 56 ++++++++++++++++
pkg/streamplace/cbor_gen.go | 66 ++++++++++++++++++-
pkg/streamplace/serversettings.go | 2 +
8 files changed, 169 insertions(+), 14 deletions(-)
diff --git a/js/app/components/settings/privacy-category-settings.tsx b/js/app/components/settings/privacy-category-settings.tsx
index 22c07518..c7943f04 100644
--- a/js/app/components/settings/privacy-category-settings.tsx
+++ b/js/app/components/settings/privacy-category-settings.tsx
@@ -1,4 +1,10 @@
-import { MenuContainer, MenuGroup, View, zero } from "@streamplace/components";
+import {
+ MenuContainer,
+ MenuGroup,
+ useBetaStatus,
+ View,
+ zero,
+} from "@streamplace/components";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView } from "react-native";
@@ -18,6 +24,11 @@ export function PrivacyCategorySettings() {
(state) => state.createServerSettingsRecord,
);
const debugRecordingOn = serverSettings?.debugRecording === true;
+ const livestreamRecordingOn = serverSettings?.livestreamRecording === true;
+ // The livestream-recording toggle is only meaningful for accounts in the VOD
+ // beta — the node won't record anyone else regardless of this flag — so we
+ // only surface it to them.
+ const { status: vodBetaStatus } = useBetaStatus("vod");
useEffect(() => {
if (isReady) {
@@ -38,13 +49,19 @@ export function PrivacyCategorySettings() {
description={t("debug-recording-description")}
value={debugRecordingOn}
onValueChange={(value) => {
- if (value === true) {
- createServerSettingsRecord(true);
- } else {
- createServerSettingsRecord(false);
- }
+ createServerSettingsRecord({ debugRecording: value });
}}
/>
+ {vodBetaStatus === "granted" && (
+ {
+ createServerSettingsRecord({ livestreamRecording: value });
+ }}
+ />
+ )}
diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts
index 54fca3d6..1590b247 100644
--- a/js/app/store/slices/blueskySlice.ts
+++ b/js/app/store/slices/blueskySlice.ts
@@ -121,7 +121,9 @@ export interface BlueskySlice {
followUser: (subjectDID: string) => Promise;
unfollowUser: (subjectDID: string, followUri?: string) => Promise;
getServerSettingsFromPDS: () => Promise;
- createServerSettingsRecord: (debugRecording: boolean) => Promise;
+ createServerSettingsRecord: (
+ patch: Partial>,
+ ) => Promise;
}
const uploadThumbnail = async (
@@ -1172,7 +1174,9 @@ export const createBlueskySlice: StateCreator<
}
},
- createServerSettingsRecord: async (debugRecording: boolean) => {
+ createServerSettingsRecord: async (
+ patch: Partial>,
+ ) => {
try {
const state = get() as BlueskySlice;
if (!state.pdsAgent) {
@@ -1188,9 +1192,12 @@ export const createBlueskySlice: StateCreator<
}
const streamplaceUrl = get().url;
const u = new URL(streamplaceUrl);
+ // Merge the patch onto the current record so toggling one flag doesn't
+ // clobber the others (the record holds several independent settings).
const serverSettings: PlaceStreamServerSettings.Record = {
+ ...(state.serverSettings ?? {}),
$type: "place.stream.server.settings",
- debugRecording: debugRecording,
+ ...patch,
};
const res = await state.pdsAgent.com.atproto.repo.putRecord({
diff --git a/js/components/locales/en-US/settings.ftl b/js/components/locales/en-US/settings.ftl
index 2920f106..bd8b366e 100644
--- a/js/components/locales/en-US/settings.ftl
+++ b/js/components/locales/en-US/settings.ftl
@@ -36,6 +36,8 @@ currently-translating-description = Some parts of the app may look incomplete. T
## Debug Recording
debug-recording-title = Allow { $host } to record your livestream for debugging and improving the service
debug-recording-description = Optional
+livestream-recording-title = Record my livestreams into VODs on { $host }
+livestream-recording-description = When enabled, your livestreams are saved so you can finalize and publish them as videos
## Key Management
manage-keys = Manage Keys
diff --git a/js/docs/src/content/docs/lex-reference/server/place-stream-server-settings.md b/js/docs/src/content/docs/lex-reference/server/place-stream-server-settings.md
index 75858378..1ba064e8 100644
--- a/js/docs/src/content/docs/lex-reference/server/place-stream-server-settings.md
+++ b/js/docs/src/content/docs/lex-reference/server/place-stream-server-settings.md
@@ -19,9 +19,10 @@ Record containing user settings for a particular Streamplace node
**Record Properties:**
-| Name | Type | Req'd | Description | Constraints |
-| ---------------- | --------- | ----- | ----------------------------------------------------------------------- | ----------- |
-| `debugRecording` | `boolean` | ❌ | Whether this node may archive your livestream for improving the service | |
+| Name | Type | Req'd | Description | Constraints |
+| --------------------- | --------- | ----- | ------------------------------------------------------------------------------- | ----------- |
+| `debugRecording` | `boolean` | ❌ | Whether this node may archive your livestream for improving the service | |
+| `livestreamRecording` | `boolean` | ❌ | Whether this node should record your livestreams into VODs that you can publish | |
---
@@ -43,6 +44,10 @@ Record containing user settings for a particular Streamplace node
"debugRecording": {
"type": "boolean",
"description": "Whether this node may archive your livestream for improving the service"
+ },
+ "livestreamRecording": {
+ "type": "boolean",
+ "description": "Whether this node should record your livestreams into VODs that you can publish"
}
}
}
diff --git a/lexicons/place/stream/server/settings.json b/lexicons/place/stream/server/settings.json
index 2b9e3a79..cfd50cb8 100644
--- a/lexicons/place/stream/server/settings.json
+++ b/lexicons/place/stream/server/settings.json
@@ -13,6 +13,10 @@
"debugRecording": {
"type": "boolean",
"description": "Whether this node may archive your livestream for improving the service"
+ },
+ "livestreamRecording": {
+ "type": "boolean",
+ "description": "Whether this node should record your livestreams into VODs that you can publish"
}
}
}
diff --git a/pkg/director/s3_upload.go b/pkg/director/s3_upload.go
index 5cc73019..fcb42858 100644
--- a/pkg/director/s3_upload.go
+++ b/pkg/director/s3_upload.go
@@ -12,10 +12,66 @@ import (
// (keys are liveRecPrefix + / + .m4s).
const liveRecPrefix = "live-rec/"
+// vodInviteFeature is the place.stream.beta.invite `feature` value that grants
+// VOD access. Mirrors spxrpc's vodInviteFeature (that const is unexported in
+// another package); the two must stay in sync.
+const vodInviteFeature = "vod"
+
+// shouldRecordLivestream reports whether this node should archive repoDID's
+// livestream into a VOD. Both conditions are required:
+//
+// - The user is in the VOD beta — an indexed place.stream.beta.invite with
+// feature=vod from the trusted issuer (--beta-invite-did). This mirrors the
+// upload gate (spxrpc.betaFeatureGranted): when no issuer is configured
+// (self-hosted / dev) we fall back to cli.StreamIsAllowed, the same
+// allowlist livestreaming itself uses.
+// - The user has opted in via the livestreamRecording server setting. Not
+// everyone in the beta wants their streams recorded, so it's off until
+// explicitly enabled.
+//
+// Evaluated once at stream start (like debugRecording in media.shouldRecord); a
+// mid-stream settings change takes effect on the streamer's next session. On
+// any lookup error we fail closed (do not record).
+func (ss *StreamSession) shouldRecordLivestream(ctx context.Context, repoDID string) bool {
+ inBeta := false
+ if ss.cli.BetaInviteDID != "" {
+ has, err := ss.mod.HasBetaInvite(ctx, ss.cli.BetaInviteDID, repoDID, vodInviteFeature)
+ if err != nil {
+ log.Error(ctx, "live recording: failed to check VOD beta invite", "error", err, "repoDID", repoDID)
+ return false
+ }
+ inBeta = has
+ } else {
+ inBeta = ss.cli.StreamIsAllowed(repoDID) == nil
+ }
+ if !inBeta {
+ return false
+ }
+
+ settings, err := ss.mod.GetServerSettings(ctx, ss.cli.BroadcasterHost, repoDID)
+ if err != nil {
+ log.Error(ctx, "live recording: failed to load server settings", "error", err, "repoDID", repoDID)
+ return false
+ }
+ if settings == nil {
+ return false
+ }
+ spsettings, err := settings.ToStreamplaceServerSettings()
+ if err != nil {
+ log.Error(ctx, "live recording: failed to decode server settings", "error", err, "repoDID", repoDID)
+ return false
+ }
+ return spsettings.LivestreamRecording != nil && *spsettings.LivestreamRecording
+}
+
func (ss *StreamSession) maybeStartS3Upload(ctx context.Context, repoDID string) {
if !ss.cli.S3Configured() {
return
}
+ if !ss.shouldRecordLivestream(ctx, repoDID) {
+ log.Debug(ctx, "live recording disabled for streamer (not in VOD beta or recording not enabled)", "repoDID", repoDID)
+ return
+ }
cfg := s3.Config{
Endpoint: ss.cli.S3Endpoint,
Bucket: ss.cli.S3Bucket,
diff --git a/pkg/streamplace/cbor_gen.go b/pkg/streamplace/cbor_gen.go
index 27ed4122..0c7db4ba 100644
--- a/pkg/streamplace/cbor_gen.go
+++ b/pkg/streamplace/cbor_gen.go
@@ -4119,12 +4119,16 @@ func (t *ServerSettings) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
- fieldCount := 2
+ fieldCount := 3
if t.DebugRecording == nil {
fieldCount--
}
+ if t.LivestreamRecording == nil {
+ fieldCount--
+ }
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
}
@@ -4172,6 +4176,31 @@ func (t *ServerSettings) MarshalCBOR(w io.Writer) error {
}
}
}
+
+ // t.LivestreamRecording (bool) (bool)
+ if t.LivestreamRecording != nil {
+
+ if len("livestreamRecording") > 1000000 {
+ return xerrors.Errorf("Value in field \"livestreamRecording\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("livestreamRecording"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("livestreamRecording")); err != nil {
+ return err
+ }
+
+ if t.LivestreamRecording == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if err := cbg.WriteBool(w, *t.LivestreamRecording); err != nil {
+ return err
+ }
+ }
+ }
return nil
}
@@ -4200,7 +4229,7 @@ func (t *ServerSettings) UnmarshalCBOR(r io.Reader) (err error) {
n := extra
- nameBuf := make([]byte, 14)
+ nameBuf := make([]byte, 19)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
@@ -4260,6 +4289,39 @@ func (t *ServerSettings) UnmarshalCBOR(r io.Reader) (err error) {
t.DebugRecording = &val
}
}
+ // t.LivestreamRecording (bool) (bool)
+ case "livestreamRecording":
+
+ {
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+
+ maj, extra, err = cr.ReadHeader()
+ if err != nil {
+ return err
+ }
+ if maj != cbg.MajOther {
+ return fmt.Errorf("booleans must be major type 7")
+ }
+
+ var val bool
+ switch extra {
+ case 20:
+ val = false
+ case 21:
+ val = true
+ default:
+ return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
+ }
+ t.LivestreamRecording = &val
+ }
+ }
default:
// Field doesn't exist on this type, so ignore it
diff --git a/pkg/streamplace/serversettings.go b/pkg/streamplace/serversettings.go
index ca2a9547..6790cc73 100644
--- a/pkg/streamplace/serversettings.go
+++ b/pkg/streamplace/serversettings.go
@@ -16,4 +16,6 @@ type ServerSettings struct {
LexiconTypeID string `json:"$type" cborgen:"$type,const=place.stream.server.settings"`
// debugRecording: Whether this node may archive your livestream for improving the service
DebugRecording *bool `json:"debugRecording,omitempty" cborgen:"debugRecording,omitempty"`
+ // livestreamRecording: Whether this node should record your livestreams into VODs that you can publish
+ LivestreamRecording *bool `json:"livestreamRecording,omitempty" cborgen:"livestreamRecording,omitempty"`
}
--
2.51.2
From 7bf1ff968cd0ce0c3486d4507fd8d9ae31f6c5e4 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 30 Jun 2026 13:37:12 -0700
Subject: [PATCH 32/34] live-vod: default livestream recording on for beta
users
Most users with VOD access want their streams kept, so recording defaults on
(opposite of debugRecording): a missing settings record or unset flag records,
and only an explicit livestreamRecording=false opts out. Lookup/decode errors
still fail closed.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../settings/privacy-category-settings.tsx | 3 ++-
js/app/src/screens/upload.tsx | 1 +
pkg/director/s3_upload.go | 12 +++++++-----
3 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/js/app/components/settings/privacy-category-settings.tsx b/js/app/components/settings/privacy-category-settings.tsx
index c7943f04..e548bd28 100644
--- a/js/app/components/settings/privacy-category-settings.tsx
+++ b/js/app/components/settings/privacy-category-settings.tsx
@@ -24,7 +24,8 @@ export function PrivacyCategorySettings() {
(state) => state.createServerSettingsRecord,
);
const debugRecordingOn = serverSettings?.debugRecording === true;
- const livestreamRecordingOn = serverSettings?.livestreamRecording === true;
+ // Defaults on (unlike debugRecording): only an explicit `false` turns it off.
+ const livestreamRecordingOn = serverSettings?.livestreamRecording !== false;
// The livestream-recording toggle is only meaningful for accounts in the VOD
// beta — the node won't record anyone else regardless of this flag — so we
// only surface it to them.
diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx
index c7d56cdc..23341cb4 100644
--- a/js/app/src/screens/upload.tsx
+++ b/js/app/src/screens/upload.tsx
@@ -1776,6 +1776,7 @@ export function UploadDraftsScreen() {
handleDeleteDraft(draft.uri)}
>
Discard
diff --git a/pkg/director/s3_upload.go b/pkg/director/s3_upload.go
index fcb42858..6030fc97 100644
--- a/pkg/director/s3_upload.go
+++ b/pkg/director/s3_upload.go
@@ -25,9 +25,9 @@ const vodInviteFeature = "vod"
// upload gate (spxrpc.betaFeatureGranted): when no issuer is configured
// (self-hosted / dev) we fall back to cli.StreamIsAllowed, the same
// allowlist livestreaming itself uses.
-// - The user has opted in via the livestreamRecording server setting. Not
-// everyone in the beta wants their streams recorded, so it's off until
-// explicitly enabled.
+// - The user has not turned recording off via the livestreamRecording server
+// setting. This defaults ON for beta users (most want their streams kept) —
+// the opposite of debugRecording — so only an explicit `false` opts out.
//
// Evaluated once at stream start (like debugRecording in media.shouldRecord); a
// mid-stream settings change takes effect on the streamer's next session. On
@@ -54,14 +54,16 @@ func (ss *StreamSession) shouldRecordLivestream(ctx context.Context, repoDID str
return false
}
if settings == nil {
- return false
+ // No settings record at all: default on for beta users.
+ return true
}
spsettings, err := settings.ToStreamplaceServerSettings()
if err != nil {
log.Error(ctx, "live recording: failed to decode server settings", "error", err, "repoDID", repoDID)
return false
}
- return spsettings.LivestreamRecording != nil && *spsettings.LivestreamRecording
+ // Default on: record unless the streamer explicitly set the flag to false.
+ return spsettings.LivestreamRecording == nil || *spsettings.LivestreamRecording
}
func (ss *StreamSession) maybeStartS3Upload(ctx context.Context, repoDID string) {
--
2.51.2
From 8ec1e3694427ea62bf810396ed124c6760803384 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Tue, 30 Jun 2026 16:21:12 -0700
Subject: [PATCH 33/34] Update
pkg/spxrpc/place_stream_media_finalizelivestream.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.../place_stream_media_finalizelivestream.go | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/pkg/spxrpc/place_stream_media_finalizelivestream.go b/pkg/spxrpc/place_stream_media_finalizelivestream.go
index 0ac1d9a2..cf968004 100644
--- a/pkg/spxrpc/place_stream_media_finalizelivestream.go
+++ b/pkg/spxrpc/place_stream_media_finalizelivestream.go
@@ -61,19 +61,23 @@ func (s *Server) handlePlaceStreamMediaFinalizeLivestream(ctx context.Context, b
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
+ // Create a draft VOD in the 'processing' state, inheriting the livestream's
+ // metadata and linking back to it via connections. The client no longer polls
+ // or publishes — the draft reaches 'ready' server-side and the user
+ // publishes it later from the Drafts tab.
+ draft, err := s.createLivestreamDraft(ctx, session.DID, uploadID, ls)
+ if err != nil {
+ return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+
if _, err := s.statefulDB.EnqueueTask(ctx, statedb.TaskFinalizeLivestreamVOD, statedb.FinalizeLivestreamVODTask{
UploadID: uploadID,
RepoDID: session.DID,
LivestreamURI: body.Livestream,
}, statedb.WithTaskKey("finalize-vod:"+uploadID)); err != nil {
+ _, _ = s.statefulDB.DeleteDraft(ctx, draft.URI)
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
-
- // Create a draft VOD in the 'processing' state, inheriting the livestream's
- // metadata and linking back to it via connections. The client no longer polls
- // or publishes — the draft reaches 'ready' server-side and the user
- // publishes it later from the Drafts tab.
- draft, err := s.createLivestreamDraft(ctx, session.DID, uploadID, ls)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
--
2.51.2
From b8eb48b7618126d665f499b766336e2e42bd8a05 Mon Sep 17 00:00:00 2001
From: Eli Mallon
Date: Wed, 1 Jul 2026 13:15:58 -0700
Subject: [PATCH 34/34] go: pin client_model so go.mod stops thrashing?
---
tools.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/tools.go b/tools.go
index f6bbc4a6..bc99bb38 100644
--- a/tools.go
+++ b/tools.go
@@ -7,5 +7,6 @@ import (
_ "github.com/99designs/gqlgen/graphql/introspection"
_ "github.com/bluesky-social/indigo/cmd/lexgen"
_ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint"
+ _ "github.com/prometheus/client_model/go"
_ "golang.org/x/tools/cmd/goimports"
)