mobile v0.1 with deployment for ios (#191)

* stage 1: project init

* stage 2: skeleton with navigation

* step 2.5: streams list

* step 4: stream playback experience

* step 5-6: compose experience

* fix: broken record

* transcode media particles to mp4

* build: reproducible go generate

* build: rename skaffold module for particle processor worker

* infra: increase particle processor worker resources

Was dealing with OOM errors

* tweaks to mobile

* log transcode work

* view on desktop placeholder

* tweak padding

* cap video resolution to save on memory

* infra: bump memory limits as insurance

* ux improvements

* update bundle id for mobile

* config for mobile
This commit was merged in pull request #191.
This commit is contained in:
Arjun Patel
2026-04-29 17:39:11 -07:00
committed by GitHub
parent 3a11a82cd3
commit e3461dd5cd
110 changed files with 14682 additions and 22 deletions
+2
View File
@@ -14,6 +14,8 @@ RUN ls
FROM alpine:latest AS second-stage
RUN apk add --no-cache ffmpeg ca-certificates
WORKDIR /app
COPY --from=first-stage /app/cmd/particleprocessorworker .
RUN echo "copied over binary to production stage"
+149
View File
@@ -1,11 +1,13 @@
package main
import (
"bytes"
"context"
"fmt"
"log"
"log/slog"
"os"
"os/exec"
"strings"
"time"
@@ -107,6 +109,7 @@ func main() {
updateParentLastChildCreatedAt(ctx, change.Doc)
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
transcodeMediaParticle(ctx, depotSvc, change.Doc)
recordFreemiumUsage(ctx, billingSvc, change.Doc)
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
@@ -162,6 +165,152 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech
slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
}
// transcodeMediaParticle produces an iOS-playable MP4/m4a derivative for media
// particles whose original mime type AVPlayer can't decode (notably the WebM
// the desktop recorder emits today). Skips when the source is already in an
// iOS-playable family or when a transcoded variant has already been written.
//
// ffmpeg reads directly from the GCS signed URL and writes to a local temp
// file — `+faststart` requires seekable output, so a stdout pipe wouldn't work.
// The temp file is then streamed to GCS via depotSvc.CreateFromReader (no
// presigned-PUT round-trip — the worker has direct SDK access).
func transcodeMediaParticle(ctx context.Context, depotSvc depot.Service, doc *firestore.DocumentSnapshot) {
var mediaParticle particle.FirestoreMediaParticle
if err := doc.DataTo(&mediaParticle); err != nil {
slog.Error("transcode: unable to marshal particle data", "error", err)
return
}
particleType, err := particle.ParseParticleType(mediaParticle.Type)
if err != nil {
slog.Error("transcode: invalid particle type", "error", err)
return
}
if particleType != particle.TypeMedia {
slog.Info("transcode: particle is not of type media")
return
}
// Already transcoded — re-delivery within the 5-min Firestore window.
if mediaParticle.Properties.TranscodedObjectId != "" {
return
}
// Source is already iOS-playable; nothing to do.
if isIOSPlayableMime(mediaParticle.Properties.MimeType) {
slog.Info("transcode: skipping because already playable on ios")
return
}
sourceURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
if err != nil {
slog.Error("transcode: failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
return
}
isAudio := strings.HasPrefix(mediaParticle.Properties.MimeType, "audio/")
var outputExt, outputMime string
if isAudio {
outputExt = ".m4a"
outputMime = "audio/mp4"
} else {
outputExt = ".mp4"
outputMime = "video/mp4"
}
tmp, err := os.CreateTemp("", "transcode-*"+outputExt)
if err != nil {
slog.Error("transcode: failed to create temp file", "error", err)
return
}
tmpPath := tmp.Name()
tmp.Close()
defer os.Remove(tmpPath)
transcodeCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
var args []string
if isAudio {
args = []string{
"-y", "-i", sourceURL,
"-vn",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
tmpPath,
}
} else {
// Cap encoder parallelism and lookahead to keep memory bounded — screen
// recordings come in at native display resolution (often 1440p4K) and
// libx264's per-thread lookahead/reference buffers blow past the worker's
// memory limit otherwise. Output is also downscaled to 1080p max, which
// mobile playback won't notice; the original WebM stays in GCS untouched.
args = []string{
"-y", "-i", sourceURL,
"-vf", "scale='min(1920,iw)':-2:flags=lanczos",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1",
"-x264-params", "rc-lookahead=20:ref=2",
"-threads", "2", "-filter_threads", "2",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
tmpPath,
}
}
cmd := exec.CommandContext(transcodeCtx, "ffmpeg", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
slog.Error("transcode: ffmpeg failed", "error", err, "stderr", stderr.String(), "particleID", doc.Ref.ID)
return
}
networkID, err := networkIDFromParticlePath(doc.Ref.Path)
if err != nil {
slog.Error("transcode: failed to derive network id", "error", err, "path", doc.Ref.Path)
return
}
f, err := os.Open(tmpPath)
if err != nil {
slog.Error("transcode: failed to open transcoded file", "error", err, "path", tmpPath)
return
}
defer f.Close()
newObj, err := depotSvc.CreateFromReader(ctx, depot.CreateFromReaderInput{
Prefix: networkID,
Name: "transcoded" + outputExt,
ContentType: outputMime,
}, f)
if err != nil {
slog.Error("transcode: failed to upload transcoded object", "error", err, "particleID", doc.Ref.ID)
return
}
_, err = doc.Ref.Set(ctx, map[string]interface{}{
"properties": map[string]interface{}{
"transcoded_object_id": newObj.ID,
"transcoded_mime_type": outputMime,
},
}, firestore.MergeAll)
if err != nil {
slog.Error("transcode: failed to update particle in firestore", "error", err, "particleID", doc.Ref.ID)
return
}
slog.Info("transcoded media particle", "particleID", doc.Ref.ID, "transcoded_object_id", newObj.ID, "mime", outputMime)
}
func isIOSPlayableMime(mime string) bool {
switch mime {
case "video/mp4", "video/quicktime", "audio/mp4", "audio/aac", "audio/x-m4a", "audio/mpeg":
return true
}
return false
}
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
words := make([]particle.FirestoreTranscriptWord, len(result.Words))
for i, w := range result.Words {
+3
View File
@@ -177,6 +177,7 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
@@ -184,3 +185,5 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
)
tool go.uber.org/mock/mockgen
+2
View File
@@ -477,6 +477,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+1 -1
View File
@@ -15,7 +15,7 @@ import (
stripesub "github.com/stripe/stripe-go/v85/subscription"
)
//go:generate mockgen -source ./service.go -destination ./mocks/service.go
//go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go
type Service interface {
GetStatus(ctx context.Context, networkID string) (*Status, error)
+9
View File
@@ -29,6 +29,15 @@ type PrepareUploadResult struct {
UploadHeaders map[string]string
}
// CreateFromReaderInput is for server-side direct uploads (no presigned URL).
// Used by background workers that already have the bytes on hand and don't
// need a client round-trip.
type CreateFromReaderInput struct {
Prefix string // Optional prefix for organizing objects (e.g., network_id)
Name string
ContentType string
}
// Config holds configuration for the depot service
type Config struct {
GoogleServiceAccountEmail string
+52
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"time"
@@ -20,6 +21,7 @@ const (
type Service interface {
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error)
GetByID(ctx context.Context, objectID string) (*Object, error)
GetDownloadURL(ctx context.Context, objectID string) (string, error)
Delete(ctx context.Context, objectID string) error
@@ -155,6 +157,56 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
return s.repo.getByID(ctx, objectID)
}
// CreateFromReader streams bytes directly to GCS using the storage client and
// records the depot_objects row in one shot. Unlike PrepareUpload, there is no
// signed URL or client round-trip — the caller already has the bytes. Intended
// for worker-side flows (e.g. transcoded media variants).
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
if input.Name == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
}
if input.ContentType == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
}
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
w.ContentType = input.ContentType
if _, err := io.Copy(w, body); err != nil {
// Close to release resources, then surface the original copy error.
if cerr := w.Close(); cerr != nil {
slog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
}
slog.Error("failed to stream object to GCS", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
if err := w.Close(); err != nil {
slog.Error("failed to close GCS writer", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
obj := &Object{
Name: input.Name,
ContentType: input.ContentType,
ContentLength: w.Attrs().Size,
BucketName: s.bucketName,
ObjectKey: objectKey,
ContainsContent: true,
}
created, err := s.repo.create(ctx, obj)
if err != nil {
// Best-effort: clean up the GCS object since we can't track it in the DB.
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
slog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
}
return nil, err
}
return created, nil
}
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
obj, err := s.repo.getByID(ctx, objectID)
if err != nil {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"cloud.google.com/go/firestore"
)
//go:generate mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
//go:generate go tool mockgen -source ./membershippublisher.go -destination ./mocks/membershippublisher.go
// MembershipPublisher publishes network membership changes to the live store
// (Firestore) that clients subscribe to. Postgres remains the source of truth;
+7 -5
View File
@@ -35,11 +35,13 @@ type FirestoreTranscript struct {
}
type FirestoreMediaParticleProperties struct {
ObjectId string `firestore:"object_id"`
MimeType string `firestore:"mime_type"`
DurationMs int `firestore:"duration_ms"`
SizeBytes int `firestore:"size_bytes"`
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
ObjectId string `firestore:"object_id"`
MimeType string `firestore:"mime_type"`
DurationMs int `firestore:"duration_ms"`
SizeBytes int `firestore:"size_bytes"`
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
TranscodedObjectId string `firestore:"transcoded_object_id,omitempty"`
TranscodedMimeType string `firestore:"transcoded_mime_type,omitempty"`
}
type FirestoreStreamParticle struct {
@@ -2,7 +2,7 @@ package particle
import "context"
//go:generate mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
//go:generate go tool mockgen -source ./networkmembershipchecker.go -destination ./mocks/networkmembershipchecker.go
type NetworkMembershipChecker interface {
// IsMember returns true if the humanId is a member of the network.
+1 -1
View File
@@ -3,4 +3,4 @@
// directive lives here rather than next to the source.
package aero
//go:generate mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
//go:generate go tool mockgen -destination ./mock_aero.go -package aero github.com/flowy-live/llink/genproto/aero PrimaryClient
+4 -4
View File
@@ -21,11 +21,11 @@ spec:
image: "particleprocessorworker"
resources:
requests:
memory: "52Mi"
cpu: 50m
memory: "1Gi"
cpu: 500m
limits:
memory: "52Mi"
cpu: 50m
memory: "1Gi"
cpu: 500m
env:
- name: "GCP_PROJECT"
value: "flowy-dev-440017"
+4 -4
View File
@@ -18,11 +18,11 @@ spec:
image: "particleprocessorworker"
resources:
requests:
memory: "52Mi"
cpu: 50m
memory: "1Gi"
cpu: 500m
limits:
memory: "52Mi"
cpu: 50m
memory: "1Gi"
cpu: 500m
env:
- name: "GCP_PROJECT"
value: "flowy-prod-440017"
+1 -1
View File
@@ -59,7 +59,7 @@ profiles:
apiVersion: skaffold/v4beta11
kind: Config
metadata:
name: worker
name: particleprocessor
build:
local: {}
tagPolicy: