Compare commits

...

57 Commits

Author SHA1 Message Date
Claude
bab398cb52
wip(media): editor e2e and player fixes (unverified)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:29:59 +00:00
Claude
844f7fe7c1
feat(media): web project, editor, review, and brand kit views
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:19:45 +00:00
Claude
884d8903ca
feat(media): web view skeleton for Media Studio
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:04:47 +00:00
Claude
b4083844ce
feat(media): wave 2 integration
Composition root shared by API and worker, media job handlers on every job
host, HTTP byte routes, consent re-check before bundling, and auto-fix limited
to changes that keep protection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:04:01 +00:00
Claude
90f18cea52
merge: W2 privacy 2026-10-03 18:57:49 +00:00
Claude
d88e0de1bc
wip: media composition root 2026-10-03 18:57:49 +00:00
Claude
8d07ab17d4
feat(media): anonymization, marks, consent, and the render gate
prepareSources applies every face, mark, and region protection before the
compiler runs and fails closed when one cannot be honored: all face modes,
tracking with interpolation and grown holds over lost detections, reframe_out
crops with blur fallback, synthetic identity only through a licensed provider
(blur fallback otherwise), inpainting with a quality floor that falls back to
a cover, and person-bound marks re-found next to the face. The gate runs on
the render output: classifier at the profile rate plus cuts, face tracking
matched to people locally, pixel verification of every recorded protection,
consent and age proof (always for paid video), text and technical checks.
Auto-fix proposes only compliance repairs and stops after three rounds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:57:04 +00:00
Claude
2973f5074b
merge: W2 ingest
# Conflicts:
#	apps/desktop/src/preload.test.ts
2026-10-03 18:51:13 +00:00
Claude
21edc4add7
feat(media): wire marketing into the API, add segment results and teaser reset
Brand kits are validated through saveBrandKit and return their warnings;
manual metrics refit the teaser model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:50:45 +00:00
Claude
b416ff0fdf
feat(media): ingest job handlers with bounded concurrency and disk backpressure
media.sync, media.proxy, media.analyze, and media.cluster for Media Studio:
incremental metadata sync with deletion detection, 720p CFR proxies with
1 s keyframes, tone mapping, posters and sprites, sidecar analyses stored per
model version with scored segments, and sessions, themes, and person
suggestions. Proxy and analysis work is pumped in a small per-space window,
jobs step aside when their GPU or CPU permit is taken, and a full disk or an
unreachable sidecar pauses work instead of failing assets. Re-clustering
keeps collections the user merged or split. Fixes photo posters, which
never rendered because ffmpeg yields no frame when seeking a single image.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:50:30 +00:00
Claude
f29e6379cb
merge: W2 worktree-agent-a78be97f0347925a3 2026-10-03 18:47:30 +00:00
Claude
0e82982452
merge: W2 media-w2-render 2026-10-03 18:47:29 +00:00
Claude
227703a78a
merge: W2 worktree-agent-a346544dc365ebb55 2026-10-03 18:47:29 +00:00
Claude
4876c58fca
style(media): format zip test 2026-10-03 18:46:41 +00:00
Claude
7c744b98a0
feat(media): per-segment results and copy checks on every field 2026-10-03 18:46:36 +00:00
Claude
3ebe0fe47b
feat(media): ffmpeg renderer, render job and real render integration test
Fail closed when anonymization cannot prepare every clip, retry a failing
hardware encoder in software, verify each ffmpeg output exists, and fail
renders whose video or poster still carries source metadata. Cover parsing,
encoder detection, two-pass loudness, abort and cleanup, the render job
(auto-fix, cancel, outputs) on memory and Postgres, and a synthetic
16:9 to 9:16 render plus a region blur check with ffprobe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:44:58 +00:00
Claude
03e4961c9f
test(media): cover marketing plans, brand kits, bundles, publishing and learning 2026-10-03 18:43:37 +00:00
Claude
ffcc47710d
test(media): type the media router test client 2026-10-03 18:39:28 +00:00
Claude
e601b26292
feat(media): media API procedures, chunked uploads, and authorization tests 2026-10-03 18:37:51 +00:00
Claude
872fdd2194
feat(media): marketing service, brand kits, intro variants and bundle publisher 2026-10-03 18:36:53 +00:00
Claude
87d5be4daf
fix(desktop): iterate Photos persistent changes as a sequence
PHPersistentChangeFetchResult has no enumerateChanges; it is a Swift sequence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:31:45 +00:00
Claude
44396969fc
merge: main into media studio
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 18:30:25 +00:00
Claude
393137be9a
Merge remote-tracking branch 'origin/media-wip/w2-render' into media-w2-render 2026-10-03 18:29:04 +00:00
Claude
4721f00546
Merge remote-tracking branch 'origin/media-wip/w2-api' into worktree-agent-a346544dc365ebb55 2026-10-03 18:28:41 +00:00
Claude
d6957c4670
Merge remote-tracking branch 'origin/media-wip/w2-privacy' into worktree-agent-ac408b7c790dd9920 2026-10-03 18:28:38 +00:00
Claude
439de0d936
merge: media base b4222ac 2026-10-03 18:28:20 +00:00
Claude
3c13961948
Merge remote-tracking branch 'origin/media-wip/w2-marketing' into worktree-agent-a78be97f0347925a3 2026-10-03 18:28:15 +00:00
Claude
f4bf545c45
Merge remote-tracking branch 'origin/media-wip/w2-ingest' into worktree-agent-afca57be83bd9f7ba 2026-10-03 18:28:03 +00:00
Claude
7f6fc678c3
wip(media): w2-privacy partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:55 +00:00
Claude
3f9a601dba
wip(media): w2-marketing partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:53 +00:00
Claude
8f38c22119
wip(media): w2-render partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:51 +00:00
Claude
239a1eceb0
wip(media): w2-api partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:49 +00:00
Claude
55e129cb7e
wip(media): w2-ingest partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:47 +00:00
Claude
b4222ac5ec
feat(media): compose profile and CI job for the media sidecar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:21:16 +00:00
Claude
bd5f637501
merge: W1-sidecar local media inference sidecar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:20:41 +00:00
Claude
5e2094f2ad
feat(media): local inference sidecar for Media Studio
FastAPI service in infra/media-worker implementing every
MediaInferenceProvider operation behind HTTP API v1, plus capabilities.
Bearer auth, MEDIA_ROOT realpath enforcement, strict request schemas,
cancellation on disconnect, GPU/CPU gates with 503 overflow, input
size/duration limits, lazy SHA256-pinned model downloads, fail-closed
redaction with atomic output, offline pytest suite with fake backends,
CPU/CUDA/ROCm Dockerfile and a license review of every model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:19:47 +00:00
Claude
a40582165a
feat(media): wire Apple Photos sync into the desktop app
Move the Photos bridge types into the desktop contract, expose
rakazoDesktop.media.photos from the preload, connect the sync runner in the
main process through an authenticated oRPC client, package and sign the
helper, and build and test it on macOS CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:15:53 +00:00
Claude
2556ed197e
feat(media): service contracts between media slices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:11:04 +00:00
Claude
475710e27e
feat(media): shared service deps and consent-gated model client
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:10:02 +00:00
Claude
d43e374534
merge: W1-domain media domain logic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:39 +00:00
Claude
a6598f09d9
test(media): cover clustering, EDL, compiler, teaser, gate and marketing
Includes compiler snapshots, injection-proof escaping checks, and an
ffmpeg integration test on synthetic testsrc/sine inputs that is skipped
when ffmpeg is not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:02 +00:00
Claude
26d95a374e
feat(media): implement pure domain logic for Media Studio
Session and theme clustering, EDL validation and timeline mapping, the
EDL-to-ffmpeg compiler with caption files and filtergraph escaping,
teaser selection and learning, the compliance gate with compliance-only
auto-fix, and the marketing helpers (UTM, hashtags, CSV metrics, intro
proposals, brand-kit checks, upload checklist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:06:02 +00:00
Claude
1aab5ab3a8
feat(media): expose asset externalId, filter by ingest state, push device deletions
The desktop Photos sync needs to map awaiting assets back to Photos items,
query only awaiting uploads, and report deletions without a full relisting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:00:02 +00:00
Claude
51445e9442
merge: W1-desktop Apple Photos helper and sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:57:20 +00:00
Claude
0ee6c664d2
build(db): depend on @rakazo/media for coordinate rounding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:57:02 +00:00
Claude
c877343218
merge: W1-db media repositories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:56:32 +00:00
Claude
d3c6f262ad
feat(media): Apple Photos helper and client-pushed desktop sync
Adds the macOS photos-helper Swift CLI (PhotoKit listing with stable
cursors, persistent change tokens, iCloud-aware original export with a
path guard and SHA-256) plus XCTests for its PhotoKit-free core, and the
Electron main-process side: a validated helper client, a resumable
metadata push and chunked original upload against the media contract,
and IPC handlers that stay unavailable off macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:56:14 +00:00
Claude
5e155a0850
test(media): Postgres and unit tests for the media repositories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:55:49 +00:00
Claude
c1c39b6e46
feat(media): space-scoped Media Studio repositories
Sources, assets with keyset search, analyses, segments, uploads,
collections with re-clustering, people with consent and complete
biometric removal, marks, brand kits, projects with versioned plans,
renders with atomic claims, publications, metrics and the funnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:55:49 +00:00
Claude
c3f06e4a12
feat(media): wire media adapters and ffmpeg for CI media tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:53:43 +00:00
Claude
0c4e0202f3
merge: W1-adapters emulator, sidecar client, folder source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:52:59 +00:00
Claude
270ea8068a
feat(media): inference emulator, sidecar client, folder source and factory
Add the Media Studio adapters behind the frozen provider contracts: a
deterministic offline inference emulator driven by file-name markers that
renders real redactions with ffmpeg, a synthetic emulator library, the
bearer-auth HTTP client for the local media sidecar with validated
responses, typed errors, busy retries and timeouts, a lazily paginated
folder source that never follows symlinks, and the configuration factory.
A shared conformance suite runs against the emulator and, in the Docker
integration job, against the sidecar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:52:17 +00:00
Claude
f98fd9703c
feat(media): export motion graphics renderer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:48:29 +00:00
Claude
759862fef4
merge: W1-motion motion graphics renderer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:47:57 +00:00
Claude
b5cef21f44
feat(media): resvg motion graphics renderer with brand-kit templates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:47:24 +00:00
Claude
7ccc96ac11
feat(media): shared geo, path-safety and profile loading helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:34:04 +00:00
Claude
a663764ee8
feat(media): contracts and foundation for Media Studio
Freeze the shared contracts every Media Studio slice builds on: zod DTOs,
EDL, platform profile, brand kit, gate and render plan schemas; the media
RPC surface with a scaffold router; provider interfaces for sources,
inference, rendering, motion graphics and publishing; media background
jobs; the Prisma models and migration; and the @rakazo/media domain
package signatures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 17:32:52 +00:00
254 changed files with 55425 additions and 6 deletions

View File

@ -236,3 +236,15 @@ EVOO_COCKPIT_CORS_ORIGIN=
# Expo: English or Simplified Chinese (en | zh-CN). Overridden by SecureStore key
# rakazo.uiLocale from Account → Language.
# EXPO_PUBLIC_DEFAULT_UI_LOCALE=en
# Optional Media Studio worker (local ffmpeg + ML sidecar, compose profile "media").
# Without it, Media Studio stays hidden and media jobs are skipped.
# Compose: http://media-worker:8790. Bare-metal dev: http://127.0.0.1:8790.
# MEDIA_WORKER_URL=http://media-worker:8790
# At least 32 random characters.
# MEDIA_WORKER_TOKEN=REPLACE_WITH_RANDOM_TOKEN
# Private platform profiles that override the shipped ones. Keep outside the repo.
# MEDIA_PROFILES_DIR=~/.evoo/media-profiles
# Media jobs per worker process: model inference (GPU) and ffmpeg work (CPU).
# MEDIA_GPU_CONCURRENCY=1
# MEDIA_CPU_CONCURRENCY=2

View File

@ -170,6 +170,15 @@ jobs:
- run: pnpm db:generate
env:
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
- name: Ensure ffmpeg for media tests
shell: bash
run: |
if command -v ffmpeg >/dev/null && command -v ffprobe >/dev/null; then exit 0; fi
if sudo -n true 2>/dev/null; then
sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
else
echo "::warning::ffmpeg is missing on this runner; media render tests will be skipped"
fi
- run: pnpm test
test-integration:

View File

@ -0,0 +1,32 @@
name: desktop-photos-helper
on:
pull_request:
paths: &photos-helper-paths
- ".github/workflows/desktop-photos-helper.yml"
- "apps/desktop/native/photos-helper/**"
push:
branches: [main]
paths: *photos-helper-paths
permissions:
contents: read
concurrency:
group: desktop-photos-helper-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
swift:
name: Photos helper build and XCTest
runs-on: [self-hosted, evoo-ci, macOS]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Build and test
working-directory: apps/desktop/native/photos-helper
run: |
set -euo pipefail
swift build -c release
swift test

32
.github/workflows/media-worker.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: media-worker
on:
pull_request:
paths: &media-worker-paths
- ".github/workflows/media-worker.yml"
- "infra/media-worker/**"
push:
branches: [main]
paths: *media-worker-paths
permissions:
contents: read
concurrency:
group: media-worker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
sidecar:
name: Media sidecar
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Build test image (CPU)
run: docker build --build-arg ACCELERATOR=cpu --target test -t rakazo/media-worker:ci-test infra/media-worker
- name: Pytest (offline, fake model backends)
run: docker run --rm --network none rakazo/media-worker:ci-test
- name: Build runtime image (CPU)
run: docker build --build-arg ACCELERATOR=cpu -t rakazo/media-worker:ci infra/media-worker

View File

@ -145,6 +145,14 @@ jobs:
umask 077
printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY"
chmod 600 "$APPLE_API_KEY"
- name: Build and test the universal Photos helper
if: runner.os == 'macOS'
working-directory: apps/desktop/native/photos-helper
run: |
set -euo pipefail
swift test
swift build -c release --arch arm64 --arch x86_64
lipo -verify_arch .build/apple/Products/Release/photos-helper arm64 x86_64
- name: Package signed and notarized universal macOS app
if: runner.os == 'macOS'
env:
@ -174,6 +182,10 @@ jobs:
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
helper="$app/Contents/Resources/bin/photos-helper"
codesign --verify --strict --verbose=2 "$helper"
codesign -d --entitlements - --xml "$helper" | grep -Fq "com.apple.security.personal-information.photos-library"
/usr/libexec/PlistBuddy -c 'Print :NSPhotoLibraryUsageDescription' "$app/Contents/Info.plist"
- name: Require Windows signing credentials
if: runner.os == 'Windows'

View File

@ -19,6 +19,7 @@
"@rakazo/core": "workspace:*",
"@rakazo/db": "workspace:*",
"@rakazo/logging": "workspace:*",
"@rakazo/media": "workspace:*",
"@rakazo/memory": "workspace:*",
"hono": "4.13.7"
},

View File

@ -25,6 +25,8 @@ import {
createCloudAgentConnection,
createConnectorStack,
createJobReconciler,
createMediaJobHandlers,
createMediaServices,
createMessagingContextLoader,
createMessagingTeamChatSender,
createProfileConsolidationScheduler,
@ -92,6 +94,7 @@ import { cors } from "hono/cors";
import type { AppEnv } from "./env.js";
import { loadEnv } from "./env.js";
import { mountLocalSettings } from "./local-settings.js";
import { mountMediaHttpRoutes } from "./media-http.js";
import {
createMessagingInboundHandler,
teamChatSenderCanWakeMessageRoutines,
@ -414,6 +417,18 @@ export async function createApp(
shutdownSignal: shutdown.signal,
});
const mediaServices = createMediaServices({
config: env.media,
prisma,
jobs,
artifacts,
runtime,
realtime,
resolveModel: (scope) => executor.resolveModel(scope),
});
const mediaJobs = mediaServices
? createMediaJobHandlers(mediaServices, { signal: shutdown.signal })
: null;
const jobHandlers = createBackgroundJobHandlers({
executor,
prisma,
@ -421,6 +436,7 @@ export async function createApp(
home,
jobs,
events,
...(mediaJobs ? { media: mediaJobs.handlers } : {}),
workerId: "api",
runtime,
secretStore: secrets,
@ -438,6 +454,11 @@ export async function createApp(
});
await graphileWorkerHost.start(jobHandlers);
}
if (mediaJobs) {
void mediaJobs
.resume()
.catch((error) => getLogger().error("media ingest resume failed", error));
}
const reconciler = inMemoryJobs
? createJobReconciler({
prisma,
@ -469,6 +490,7 @@ export async function createApp(
remoteConnectors,
artifacts,
dataDir: env.dataDir,
mediaServices,
messaging: {
enabled: Boolean(messaging),
providers: messaging?.platforms().map((platform) => platform.provider) ?? [],
@ -583,6 +605,13 @@ export async function createApp(
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
return actor;
});
mountMediaHttpRoutes(app, { services: mediaServices }, async (c) => {
const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) });
if (!session?.user) return null;
return requireMembership(prisma, session.user.id, c.req.header("x-rakazo-space-id")).catch(
() => null,
);
});
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
// Shared with stop so a shutdown during retry delays does not restart polling.
let messagingStopped = false;

View File

@ -1,4 +1,6 @@
import type { MediaConfig } from "@rakazo/adapters";
import {
mediaConfigFromEnv,
resolveCloudAgentProvider,
resolveDeploymentModel,
resolveSandboxProvider,
@ -15,6 +17,8 @@ export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapt
export interface AppEnv {
nodeEnv: string;
/** Media Studio; disabled unless a sidecar (or the scripted emulator) is configured. */
media: MediaConfig;
desktopStackToken?: string;
databaseUrl: string;
realtimeDatabaseUrl: string;
@ -106,6 +110,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
const updaterToken = optional(source.RAKAZO_UPDATER_TOKEN);
return {
nodeEnv: source.NODE_ENV ?? "",
media: mediaConfigFromEnv(source),
databaseUrl: required(source, "DATABASE_URL"),
realtimeDatabaseUrl: source.REALTIME_DATABASE_URL ?? required(source, "DATABASE_URL"),
desktopStackToken: optional(source.RAKAZO_DESKTOP_STACK_TOKEN),

265
apps/api/src/media-http.ts Normal file
View File

@ -0,0 +1,265 @@
import { createReadStream } from "node:fs";
import { mkdir, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import type { MediaServices } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { IsolationError, MediaStateConflictError } from "@rakazo/db";
import { assertInside, PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import type { Context, Hono } from "hono";
import { cancelBody } from "./http-body.js";
import { MEDIA_CHUNK_TIMEOUT_MS, UploadChunkError, writeUploadChunk } from "./media-uploads.js";
/*
* Byte routes Media Studio cannot express as JSON RPC: chunk uploads and
* streaming of proxies, thumbnails, renders, and bundles. Every id is checked
* against the actor's space before a path is derived, and every path is
* resolved inside the media root.
*/
const ID = /^[A-Za-z0-9_-]{1,64}$/;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
const CHUNK_INDEX = /^[0-9]{1,7}$/;
const CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".srt": "application/x-subrip",
".vtt": "text/vtt; charset=utf-8",
".json": "application/json",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".zip": "application/zip",
".m4a": "audio/mp4",
".wav": "audio/wav",
};
export function mediaContentType(fileName: string): string {
return CONTENT_TYPES[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
}
export type ByteRange = { start: number; end: number };
/**
* Parse a single `bytes=` range. Returns null for no (or an ignorable
* multi-part) range and "unsatisfiable" when it cannot be served.
*/
export function parseByteRange(
header: string | undefined,
size: number,
): ByteRange | null | "unsatisfiable" {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return header.includes(",") ? null : "unsatisfiable";
const [, first = "", last = ""] = match;
if (first === "" && last === "") return "unsatisfiable";
if (first === "") {
const suffix = Number(last);
if (suffix === 0 || size === 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
if (start >= size || end < start) return "unsatisfiable";
return { start, end };
}
async function sendFile(
c: Context,
absolutePath: string,
options: { fileName: string; download?: boolean },
): Promise<Response> {
const info = await stat(absolutePath).catch(() => null);
if (!info?.isFile()) return c.json({ error: "Not found" }, 404);
const size = info.size;
const headers: Record<string, string> = {
"content-type": mediaContentType(options.fileName),
"accept-ranges": "bytes",
"cache-control": "private, max-age=300",
};
if (options.download) {
headers["content-disposition"] = `attachment; filename="${options.fileName}"`;
}
const range = parseByteRange(c.req.header("range"), size);
if (range === "unsatisfiable") {
return new Response(null, {
status: 416,
headers: { ...headers, "content-range": `bytes */${size}` },
});
}
if (c.req.method === "HEAD") {
return new Response(null, {
status: 200,
headers: { ...headers, "content-length": String(size) },
});
}
const start = range?.start ?? 0;
const end = range?.end ?? size - 1;
const body =
size === 0
? null
: (Readable.toWeb(
createReadStream(absolutePath, { start, end }),
) as ReadableStream<Uint8Array>);
return new Response(body, {
status: range ? 206 : 200,
headers: {
...headers,
"content-length": String(size === 0 ? 0 : end - start + 1),
...(range ? { "content-range": `bytes ${start}-${end}/${size}` } : {}),
},
});
}
function mediaHttpError(c: Context, error: unknown): Response {
if (error instanceof IsolationError || error instanceof PathOutsideRootError) {
return c.json({ error: "Not found" }, 404);
}
if (error instanceof UploadChunkError) return c.json({ error: error.message }, error.status);
if (error instanceof MediaStateConflictError) return c.json({ error: error.message }, 409);
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") {
return c.json({ error: "Not found" }, 404);
}
throw error;
}
export function mountMediaHttpRoutes(
app: Hono,
deps: {
/** Null when Media Studio is not configured: every route answers 404. */
services: MediaServices | null;
chunkTimeoutMs?: number;
},
resolveActor: (c: Context) => Promise<Actor | null>,
) {
async function authorize(
c: Context,
): Promise<
{ actor: Actor; services: MediaServices; response?: undefined } | { response: Response }
> {
const actor = await resolveActor(c);
if (!actor) return { response: c.json({ error: "Unauthorized" }, 401) };
if (!deps.services)
return { response: c.json({ error: "Media Studio is not configured" }, 404) };
return { actor, services: deps.services };
}
async function mediaFile(services: MediaServices, relative: string | null) {
if (!relative) return null;
await mkdir(services.deps.mediaRoot, { recursive: true });
return resolveInside(services.deps.mediaRoot, relative);
}
app.put("/api/media/uploads/:uploadId/chunks/:index", async (c) => {
const auth = await authorize(c);
if (auth.response) {
cancelBody(c.req.raw.body);
return auth.response;
}
const uploadId = c.req.param("uploadId");
const index = c.req.param("index");
if (!ID.test(uploadId) || !CHUNK_INDEX.test(index)) {
cancelBody(c.req.raw.body);
return c.json({ error: "Not found" }, 404);
}
const lengthHeader = c.req.header("content-length");
const declared = lengthHeader && /^[0-9]+$/.test(lengthHeader) ? Number(lengthHeader) : null;
const timeout = AbortSignal.timeout(deps.chunkTimeoutMs ?? MEDIA_CHUNK_TIMEOUT_MS);
const body = c.req.raw.body;
try {
const upload = await writeUploadChunk({
services: auth.services,
spaceId: auth.actor.spaceId,
uploadId,
index: Number(index),
body,
declaredLength: declared,
signal: AbortSignal.any([timeout, c.req.raw.signal]),
});
return c.json(upload);
} catch (error) {
cancelBody(body);
if (timeout.aborted) return c.json({ error: "Chunk took too long" }, 408);
return mediaHttpError(c, error);
}
});
app.get("/api/media/renders/:renderId/files/:fileName", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const renderId = c.req.param("renderId");
const fileName = c.req.param("fileName");
if (!ID.test(renderId) || !FILE_NAME.test(fileName) || fileName.includes("..")) {
return c.json({ error: "Not found" }, 404);
}
try {
const render = await auth.services.deps.repos.getRenderRecord(auth.actor.spaceId, renderId);
const download = c.req.query("download") === "1";
// Only a render that passed its gate leaves the app as a download.
if (download && render.status !== "done") return c.json({ error: "Not found" }, 404);
const absolute = await auth.services.render.outputPath(auth.actor, renderId, fileName);
if (!absolute) return c.json({ error: "Not found" }, 404);
await mkdir(auth.services.deps.mediaRoot, { recursive: true });
const inside = await assertInside(
await resolveInside(
auth.services.deps.mediaRoot,
`${auth.actor.spaceId}/renders/${renderId}`,
),
absolute,
);
return await sendFile(c, inside, { fileName, download });
} catch (error) {
return mediaHttpError(c, error);
}
});
for (const kind of ["thumb", "proxy"] as const) {
app.get(`/api/media/assets/:assetId/${kind}`, async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const assetId = c.req.param("assetId");
if (!ID.test(assetId)) return c.json({ error: "Not found" }, 404);
try {
const asset = await auth.services.deps.repos.getAssetRecord(auth.actor.spaceId, assetId);
const relative = kind === "thumb" ? asset.thumbPath : asset.proxyPath;
const absolute = await mediaFile(auth.services, relative);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, { fileName: path.basename(absolute) });
} catch (error) {
return mediaHttpError(c, error);
}
});
}
app.get("/api/media/bundles/:publicationId", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const publicationId = c.req.param("publicationId");
if (!ID.test(publicationId)) return c.json({ error: "Not found" }, 404);
try {
const publication = await auth.services.deps.repos.getPublicationRecord(
auth.actor.spaceId,
publicationId,
);
if (publication.bundleArtifactId) return c.json({ error: "Not found" }, 404);
const absolute = await mediaFile(
auth.services,
`${auth.actor.spaceId}/bundles/${publication.id}/bundle.zip`,
);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, {
fileName: `bundle-${publication.id}.zip`,
download: true,
});
} catch (error) {
return mediaHttpError(c, error);
}
});
}

View File

@ -0,0 +1,389 @@
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
import type {
MediaActor,
MediaIngestService,
MediaMarketingService,
MediaPrivacyService,
MediaRenderService,
MediaServiceDeps,
MediaServices,
} from "@rakazo/adapters";
import type { PlatformProfile } from "@rakazo/contracts";
import { mediaRpcShapes } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
/*
* In-memory stand-ins for the media service slices, for API tests only. They
* record what the router hands them and do the minimum through the real
* repositories; nothing here renders, detects, or publishes.
*/
export interface MediaServiceCall {
service: "ingest" | "privacy" | "render" | "marketing";
method: string;
actor: MediaActor;
input: unknown;
}
export interface FakeMediaServicesOptions {
repos: MediaRepos;
mediaRoot: string;
prisma?: PrismaClient;
inference?: MediaInferenceProvider | null;
profiles?: PlatformProfile[];
now?: () => Date;
/** Render id → absolute output path the fake render service reports. */
renderOutputs?: Map<string, string>;
onUploadCompleted?: (
actor: MediaActor,
input: { assetId: string; tempPath: string; sha256: string; fileName: string },
) => Promise<void>;
}
export function fakeMediaServices(options: FakeMediaServicesOptions): {
services: MediaServices;
calls: MediaServiceCall[];
} {
const calls: MediaServiceCall[] = [];
const { repos } = options;
const record = (
service: MediaServiceCall["service"],
method: string,
actor: MediaActor,
input: unknown,
) => {
calls.push({ service, method, actor: { spaceId: actor.spaceId, userId: actor.userId }, input });
};
const ingest: MediaIngestService = {
async pushItems(actor, input) {
record("ingest", "pushItems", actor, input);
return { created: 0, updated: 0, unchanged: 0, deleted: 0 };
},
async requestSync(actor, sourceId) {
record("ingest", "requestSync", actor, sourceId);
},
async uploadCompleted(actor, input) {
record("ingest", "uploadCompleted", actor, input);
await options.onUploadCompleted?.(actor, input);
},
async recluster(actor) {
record("ingest", "recluster", actor, null);
},
async removeSource(actor, sourceId) {
record("ingest", "removeSource", actor, sourceId);
await repos.removeSource(actor.spaceId, sourceId);
},
};
const privacy: MediaPrivacyService = {
async prepareSources() {
throw new Error("prepareSources is not part of the API fakes");
},
async gate() {
throw new Error("gate is not part of the API fakes");
},
async createMark(actor, input) {
record("privacy", "createMark", actor, input);
return repos.createMark(actor.spaceId, input);
},
async updateMark(actor, input) {
record("privacy", "updateMark", actor, input);
const { markId, ...patch } = input;
return repos.updateMark(actor.spaceId, markId, patch);
},
async removePerson(actor, personId) {
record("privacy", "removePerson", actor, personId);
await repos.removePersonCompletely(actor.spaceId, personId);
},
};
const render: MediaRenderService = {
async start(actor, input) {
record("render", "start", actor, input);
return repos.createRender(actor.spaceId, { ...input, createdBy: actor.userId });
},
async cancel(actor, renderId) {
record("render", "cancel", actor, renderId);
return repos.cancelRender(actor.spaceId, renderId);
},
async outputPath(actor, renderId, fileName) {
record("render", "outputPath", actor, { renderId, fileName });
const file = options.renderOutputs?.get(renderId);
return file?.endsWith(`/${fileName}`) ? file : null;
},
};
const marketing: MediaMarketingService = {
async autoPlan(actor, input) {
record("marketing", "autoPlan", actor, input);
return [];
},
async submitPlan(actor, input) {
record("marketing", "submitPlan", actor, input);
return { ok: false, errors: [] };
},
async introVariants(actor, input) {
record("marketing", "introVariants", actor, input);
return [];
},
async thumbnails(actor, input) {
record("marketing", "thumbnails", actor, input);
return [];
},
async copy(actor, input) {
record("marketing", "copy", actor, input);
const render = await repos.getRender(actor.spaceId, input.renderId);
return {
profileId: render.profileId,
language: input.language,
title: "Title",
hook: "Hook",
caption: "Caption",
hashtags: [],
issues: [],
};
},
async createPublication(actor, input) {
record("marketing", "createPublication", actor, input);
return repos.createPublication(actor.spaceId, actor.userId, {
...input,
trackingUrl: null,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
});
},
async bundle(actor, publicationId) {
record("marketing", "bundle", actor, publicationId);
return repos.markPublicationBundled(actor.spaceId, publicationId, null);
},
async cancelPublication(actor, publicationId) {
record("marketing", "cancelPublication", actor, publicationId);
return repos.cancelPublication(actor.spaceId, publicationId);
},
async importMetricsCsv(actor, input) {
record("marketing", "importMetricsCsv", actor, input);
return { imported: 0, skipped: [] };
},
};
const deps = {
prisma: options.prisma,
repos,
jobs: { publish: async () => undefined },
inference: options.inference ?? null,
renderer: {
id: "fake",
hardwareEncoders: async () => ["h264_fake"],
render: async () => {
throw new Error("render is not part of the API fakes");
},
},
motion: { id: "fake", templates: () => [], render: async () => ({ path: "", hasAlpha: true }) },
publisher: { id: "fake", publish: async () => ({ status: "bundled", externalUrl: null }) },
shortener: null,
sources: {},
mediaRoot: options.mediaRoot,
profiles: async () => ({
profiles: new Map(
(options.profiles ?? []).map((profile) => [
profile.id,
{ profile, origin: "builtin" as const },
]),
),
errors: [],
}),
artifacts: {},
model: { generate: async () => null },
publish: async () => undefined,
now: options.now ?? (() => new Date()),
} as unknown as MediaServiceDeps;
return { services: { deps, ingest, privacy, render, marketing }, calls };
}
/** Every RPC path of the media contract, e.g. "sources/list". */
export function mediaProcedurePaths(): string[] {
const paths: string[] = [];
const walk = (node: Record<string, unknown>, prefix: string[]) => {
for (const [key, value] of Object.entries(node)) {
const child = value as Record<string, unknown>;
if ("input" in child && "output" in child) paths.push([...prefix, key].join("/"));
else walk(child, [...prefix, key]);
}
};
walk(mediaRpcShapes, []);
return paths;
}
export interface MediaSampleIds {
sourceId: string;
assetId: string;
secondAssetId: string;
segmentId: string;
trackId: string;
personId: string;
markId: string;
collectionId: string;
brandKitId: string;
projectId: string;
planId: string;
renderId: string;
publicationId: string;
mappingId: string;
uploadId: string;
}
/**
* How a procedure must treat another space's ids: refuse them as not found,
* answer only from the caller's own space, or be covered by a dedicated test.
*/
export type ForeignExpectation = "not_found" | "scoped" | "dedicated";
/** A valid input for every media procedure, built from one space's ids. */
export function sampleMediaInputs(
ids: MediaSampleIds,
kit: unknown,
): Record<string, { input: unknown; foreign: ForeignExpectation }> {
const at = "2026-09-01T10:00:00.000Z";
return {
capabilities: { input: {}, foreign: "scoped" },
"sources/list": { input: {}, foreign: "scoped" },
"sources/createFolder": { input: { label: "Footage", path: "/" }, foreign: "dedicated" },
"sources/registerClient": {
input: { kind: "ios_library", label: "Phone", deviceId: "device-sample" },
foreign: "scoped",
},
"sources/pushItems": {
input: { sourceId: ids.sourceId, items: [], syncToken: null, complete: true },
foreign: "not_found",
},
"sources/sync": { input: { sourceId: ids.sourceId }, foreign: "not_found" },
"sources/remove": { input: { sourceId: ids.sourceId }, foreign: "not_found" },
"assets/search": { input: {}, foreign: "scoped" },
"assets/get": { input: { assetId: ids.assetId }, foreign: "not_found" },
"assets/setPaidOnly": {
input: { segmentIds: [ids.segmentId], paidOnly: true },
foreign: "not_found",
},
"assets/requestUpload": { input: { assetIds: [ids.assetId] }, foreign: "not_found" },
"uploads/begin": {
input: {
assetId: ids.secondAssetId,
sizeBytes: 10,
sha256: "a".repeat(64),
fileName: "clip.mp4",
},
foreign: "not_found",
},
"uploads/status": { input: { uploadId: ids.uploadId }, foreign: "not_found" },
"uploads/complete": { input: { uploadId: ids.uploadId }, foreign: "not_found" },
"collections/list": { input: {}, foreign: "scoped" },
"collections/get": { input: { collectionId: ids.collectionId }, foreign: "not_found" },
"collections/rename": {
input: { collectionId: ids.collectionId, title: "Renamed" },
foreign: "not_found",
},
"collections/merge": {
input: { collectionIds: [ids.collectionId, `${ids.collectionId}x`] },
foreign: "not_found",
},
"collections/split": {
input: { collectionId: ids.collectionId, atAssetId: ids.assetId },
foreign: "not_found",
},
"collections/createManual": {
input: { title: "Picks", assetIds: [ids.assetId] },
foreign: "not_found",
},
"collections/recluster": { input: {}, foreign: "scoped" },
"people/list": { input: {}, foreign: "scoped" },
"people/update": { input: { personId: ids.personId, displayName: "X" }, foreign: "not_found" },
"people/merge": {
input: { personIds: [ids.personId, `${ids.personId}x`] },
foreign: "not_found",
},
"people/assignTrack": { input: { trackId: ids.trackId, personId: null }, foreign: "not_found" },
"people/recordConsent": {
input: {
personId: ids.personId,
consentArtifactId: null,
consentRecordedAt: at,
ageVerificationMethod: "platform_verified",
ageVerifiedAt: at,
},
foreign: "not_found",
},
"people/remove": { input: { personId: ids.personId }, foreign: "not_found" },
"marks/list": { input: { assetId: ids.assetId }, foreign: "scoped" },
"marks/create": {
input: { kind: "tattoo", assetId: ids.assetId, prompt: { tMs: 0 }, treatment: "blur" },
foreign: "not_found",
},
"marks/update": { input: { markId: ids.markId, label: "x" }, foreign: "not_found" },
"marks/remove": { input: { markId: ids.markId }, foreign: "not_found" },
"brandKits/list": { input: {}, foreign: "scoped" },
"brandKits/upsert": { input: { id: ids.brandKitId, kit }, foreign: "not_found" },
"brandKits/remove": { input: { id: ids.brandKitId }, foreign: "not_found" },
"brandKits/templates": { input: {}, foreign: "scoped" },
"brandKits/introVariants": { input: { id: ids.brandKitId }, foreign: "not_found" },
"profiles/list": { input: {}, foreign: "scoped" },
"profiles/get": { input: { profileId: "example-short-vertical" }, foreign: "scoped" },
"projects/list": { input: {}, foreign: "scoped" },
"projects/get": { input: { projectId: ids.projectId }, foreign: "not_found" },
"projects/create": {
input: {
title: "Teaser",
goal: "teaser",
collectionIds: [ids.collectionId],
profileIds: ["example-short-vertical"],
},
foreign: "not_found",
},
"projects/update": { input: { projectId: ids.projectId, title: "X" }, foreign: "not_found" },
"projects/remove": { input: { projectId: ids.projectId }, foreign: "not_found" },
"projects/autoPlan": { input: { projectId: ids.projectId }, foreign: "not_found" },
"plans/submit": { input: { projectId: ids.projectId, edl: {} }, foreign: "not_found" },
"renders/start": { input: { planId: ids.planId, quality: "draft" }, foreign: "not_found" },
"renders/get": { input: { renderId: ids.renderId }, foreign: "not_found" },
"renders/cancel": { input: { renderId: ids.renderId }, foreign: "not_found" },
"renders/thumbnails": { input: { renderId: ids.renderId }, foreign: "not_found" },
"marketing/copy": { input: { renderId: ids.renderId }, foreign: "not_found" },
"publications/list": { input: { projectId: ids.projectId }, foreign: "scoped" },
"publications/create": { input: { renderId: ids.renderId }, foreign: "not_found" },
"publications/bundle": { input: { publicationId: ids.publicationId }, foreign: "not_found" },
"publications/markPublished": {
input: { publicationId: ids.publicationId, externalUrl: null },
foreign: "not_found",
},
"publications/cancel": { input: { publicationId: ids.publicationId }, foreign: "not_found" },
"metrics/mappings": { input: {}, foreign: "scoped" },
"metrics/saveMapping": {
input: {
id: ids.mappingId,
name: "Imported",
columns: { publication: "id", observedAt: "date" },
publicationKey: "id",
},
foreign: "not_found",
},
"metrics/importCsv": {
input: { mappingId: ids.mappingId, csv: "id,date" },
foreign: "not_found",
},
"metrics/record": {
input: {
publicationId: ids.publicationId,
observedAt: at,
views: 1,
clicks: 0,
conversions: 0,
revenueCents: 0,
currency: "EUR",
},
foreign: "not_found",
},
"metrics/funnel": { input: { projectId: ids.projectId }, foreign: "not_found" },
"metrics/segments": { input: { projectId: ids.projectId }, foreign: "not_found" },
"metrics/resetTeaserModel": { input: {}, foreign: "scoped" },
};
}

View File

@ -0,0 +1,244 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, open, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { MediaActor, MediaServices } from "@rakazo/adapters";
import type { MediaUpload } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import { MediaStateConflictError } from "@rakazo/db";
import { resolveInside } from "@rakazo/media/node";
/*
* Resumable chunked uploads. Each chunk lands in its own file
* (<spaceId>/uploads/<uploadId>/chunks/<index>.part, written to a temp name and
* renamed, so a repeated or concurrent PUT of the same index is harmless).
* Completion concatenates the chunks in order into <spaceId>/uploads/<uploadId>/file
* while hashing, verifies size and SHA-256, and hands the file to ingest.
*
* Limits: the contract caps the total size, every chunk must have its exact
* length, one chunk must arrive within MEDIA_CHUNK_TIMEOUT_MS, and an upload
* that saw no progress for MEDIA_UPLOAD_IDLE_MS expires and must start over.
*/
/** A single chunk must arrive within this window; slow links resume per chunk. */
export const MEDIA_CHUNK_TIMEOUT_MS = 10 * 60_000;
/** An open upload without a new chunk for this long is failed and its bytes removed. */
export const MEDIA_UPLOAD_IDLE_MS = 24 * 60 * 60_000;
export class UploadChunkError extends Error {
constructor(
readonly status: 400 | 409 | 413,
message: string,
) {
super(message);
this.name = "UploadChunkError";
}
}
type UploadRow = Awaited<ReturnType<MediaRepos["getUploadRecord"]>>;
type UploadSize = Pick<UploadRow, "sizeBytes" | "chunkBytes">;
export function uploadChunkCount(row: UploadSize): number {
return Number((row.sizeBytes + BigInt(row.chunkBytes) - 1n) / BigInt(row.chunkBytes));
}
/** Exact byte length chunk `index` must have: every chunk is full except the last. */
export function expectedChunkBytes(row: UploadSize, index: number): number {
const count = uploadChunkCount(row);
if (index < count - 1) return row.chunkBytes;
return Number(row.sizeBytes - BigInt(row.chunkBytes) * BigInt(count - 1));
}
function uploadDir(row: Pick<UploadRow, "spaceId" | "id">): string {
return `${row.spaceId}/uploads/${row.id}`;
}
async function mediaPath(mediaRoot: string, relative: string): Promise<string> {
await mkdir(mediaRoot, { recursive: true });
return resolveInside(mediaRoot, relative);
}
async function chunksDir(mediaRoot: string, row: UploadRow): Promise<string> {
const dir = await mediaPath(mediaRoot, `${uploadDir(row)}/chunks`);
await mkdir(dir, { recursive: true });
return dir;
}
async function removeUploadFiles(mediaRoot: string, row: UploadRow): Promise<void> {
await rm(await mediaPath(mediaRoot, uploadDir(row)), { recursive: true, force: true });
}
function isIdle(services: MediaServices, row: UploadRow): boolean {
return services.deps.now().getTime() - row.updatedAt.getTime() > MEDIA_UPLOAD_IDLE_MS;
}
/** Fail an idle open upload and drop its bytes. Returns true when it expired. */
async function expireIfIdle(services: MediaServices, row: UploadRow): Promise<boolean> {
if (row.status !== "open" || !isIdle(services, row)) return false;
await services.deps.repos.failUpload(row.spaceId, row.id).catch((error: unknown) => {
// A concurrent request already closed it; the files still go.
if (!(error instanceof MediaStateConflictError)) throw error;
});
await removeUploadFiles(services.deps.mediaRoot, row);
return true;
}
/**
* Open an upload, or resume the open one for the same asset and content. An
* idle one expires first so the client starts over with a fresh upload.
*/
export async function beginMediaUpload(
services: MediaServices,
actor: MediaActor,
input: { assetId: string; sizeBytes: number; sha256: string; fileName: string },
): Promise<MediaUpload> {
const { repos } = services.deps;
const asset = await repos.getAssetRecord(actor.spaceId, input.assetId);
// Device items wait for their original; files read from a source never upload.
if (asset.storage !== "remote" && asset.ingestState !== "awaiting_upload") {
throw new MediaStateConflictError("This item does not need an upload");
}
const begin = () =>
repos.beginUpload(actor.spaceId, { ...input, assetId: asset.id, userId: actor.userId });
const upload = await begin();
const row = await repos.getUploadRecord(actor.spaceId, upload.id);
return (await expireIfIdle(services, row)) ? begin() : upload;
}
/**
* Stream one chunk body to disk without buffering more than one network read.
* Rejects bodies longer or shorter than the chunk's exact size.
*/
export async function writeUploadChunk(input: {
services: MediaServices;
spaceId: string;
uploadId: string;
index: number;
body: ReadableStream<Uint8Array> | null;
declaredLength: number | null;
/** Aborting stops reading the body; the chunk is then discarded as incomplete. */
signal?: AbortSignal;
}): Promise<MediaUpload> {
const { services } = input;
const { repos, mediaRoot } = services.deps;
const row = await repos.getUploadRecord(input.spaceId, input.uploadId);
if (await expireIfIdle(services, row)) throw new UploadChunkError(409, "Upload expired");
if (row.status !== "open") throw new UploadChunkError(409, "Upload is not open");
const count = uploadChunkCount(row);
if (!Number.isInteger(input.index) || input.index < 0 || input.index >= count) {
throw new UploadChunkError(400, "Chunk index out of range");
}
const expected = expectedChunkBytes(row, input.index);
if (input.declaredLength !== null && input.declaredLength > expected) {
throw new UploadChunkError(413, "Chunk is too large");
}
if (!input.body) throw new UploadChunkError(400, "Chunk is empty");
const dir = await chunksDir(mediaRoot, row);
const finalPath = path.join(dir, `${input.index}.part`);
const tempPath = path.join(dir, `${input.index}.${randomUUID()}.tmp`);
const handle = await open(tempPath, "wx");
let written = 0;
let ok = false;
const reader = input.body.getReader();
const stop = () => void reader.cancel().catch(() => undefined);
input.signal?.addEventListener("abort", stop, { once: true });
try {
if (input.signal?.aborted) stop();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
written += value.byteLength;
if (written > expected) {
void reader.cancel().catch(() => undefined);
throw new UploadChunkError(413, "Chunk is too large");
}
await handle.write(value);
}
if (written !== expected) throw new UploadChunkError(400, "Chunk is incomplete");
ok = true;
} finally {
input.signal?.removeEventListener("abort", stop);
reader.releaseLock();
await handle.close();
if (!ok) await rm(tempPath, { force: true });
}
await rename(tempPath, finalPath);
return repos.markChunkReceived(input.spaceId, input.uploadId, input.index);
}
const completing = new Set<string>();
/**
* Assemble a fully received upload, verify it, hand it to ingest, and close it.
* A checksum mismatch fails the upload so the client starts a fresh one.
*/
export async function completeMediaUpload(
services: MediaServices,
actor: MediaActor,
uploadId: string,
): Promise<MediaUpload> {
const { repos, mediaRoot } = services.deps;
const row = await repos.getUploadRecord(actor.spaceId, uploadId);
if (row.status === "complete") return repos.uploadStatus(actor.spaceId, uploadId);
if (await expireIfIdle(services, row)) throw new UploadChunkError(409, "Upload expired");
if (row.status !== "open") throw new MediaStateConflictError("Upload is not open");
const received = new Set(row.receivedChunks);
const count = uploadChunkCount(row);
for (let index = 0; index < count; index++) {
if (!received.has(index)) throw new MediaStateConflictError("Upload is missing chunks");
}
if (completing.has(row.id)) throw new MediaStateConflictError("Upload is already completing");
completing.add(row.id);
try {
const dir = await chunksDir(mediaRoot, row);
const relativeFile = `${uploadDir(row)}/file`;
const filePath = await mediaPath(mediaRoot, relativeFile);
const tempFile = await mediaPath(mediaRoot, `${uploadDir(row)}/file.${randomUUID()}.tmp`);
const hash = createHash("sha256");
let total = 0;
const out = await open(tempFile, "wx");
try {
for (let index = 0; index < count; index++) {
const chunkPath = path.join(dir, `${index}.part`);
const info = await stat(chunkPath).catch(() => null);
if (!info || info.size !== expectedChunkBytes(row, index)) {
throw new MediaStateConflictError("Upload is missing chunks");
}
for await (const buffer of createReadStream(chunkPath) as AsyncIterable<Buffer>) {
hash.update(buffer);
total += buffer.byteLength;
await out.write(buffer);
}
}
} catch (error) {
await out.close();
await rm(tempFile, { force: true });
throw error;
}
await out.close();
const sha256 = hash.digest("hex");
if (sha256 !== row.sha256 || BigInt(total) !== row.sizeBytes) {
await removeUploadFiles(mediaRoot, row);
await repos.failUpload(actor.spaceId, uploadId);
throw new UploadChunkError(400, "Upload does not match its checksum");
}
await rename(tempFile, filePath);
await rm(dir, { recursive: true, force: true });
await services.ingest.uploadCompleted(actor, {
assetId: row.assetId,
tempPath: filePath,
sha256,
fileName: row.fileName,
});
// Ingest normally moves the file into the asset folder and closes the
// upload itself; otherwise the assembled file stays as the original.
const after = await repos.getUploadRecord(actor.spaceId, uploadId);
if (after.status === "complete") {
await removeUploadFiles(mediaRoot, row);
return repos.uploadStatus(actor.spaceId, uploadId);
}
return repos.completeUpload(actor.spaceId, uploadId, { storagePath: relativeFile });
} finally {
completing.delete(row.id);
}
}

View File

@ -0,0 +1,516 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRouterClient, ORPCError } from "@orpc/server";
import type { Actor } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import { createMediaRepos } from "@rakazo/db";
import { Hono } from "hono";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MediaTestDb, SeededMediaSpace } from "../../../packages/db/src/media-fixtures.js";
import {
openMediaTestDb,
seedMediaSpace,
sourceItem,
testBrandKit,
} from "../../../packages/db/src/media-fixtures.js";
import { testProfile } from "../../../packages/media/src/test-fixtures.js";
import { createMediaRouter } from "./media.js";
import { mountMediaHttpRoutes } from "./media-http.js";
import type { MediaServiceCall } from "./media-test-fakes.js";
import { fakeMediaServices, sampleMediaInputs } from "./media-test-fakes.js";
import { MEDIA_UPLOAD_IDLE_MS } from "./media-uploads.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
type Space = { userId: string; spaceId: string };
function actorOf(space: Space, isDeploymentOwner = false): Actor {
return {
userId: space.userId,
spaceId: space.spaceId,
email: `${space.userId}@rakazo.test`,
isDeploymentOwner,
};
}
function procedure(client: unknown, rpcPath: string) {
let node = client;
for (const key of rpcPath.split("/")) node = (node as Record<string, unknown>)[key];
return node as (input: unknown) => Promise<unknown>;
}
async function rpcCode(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof ORPCError) return error.code;
throw error;
}
return "OK";
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
describePostgres("media API authorization and uploads (PostgreSQL)", () => {
let db: MediaTestDb;
let repos: MediaRepos;
let mediaRoot: string;
let a: Space;
let b: Space;
let seededA: SeededMediaSpace;
let seededB: SeededMediaSpace;
let calls: MediaServiceCall[];
let renderOutputs: Map<string, string>;
let rpc: (actor: Actor) => unknown;
let app: Hono;
let currentActor: Actor | null;
function setup(options: { now?: () => Date; chunkTimeoutMs?: number } = {}) {
const fake = fakeMediaServices({
repos,
mediaRoot,
prisma: db.prisma,
profiles: [testProfile()],
renderOutputs,
...(options.now ? { now: options.now } : {}),
});
calls = fake.calls;
const router = createMediaRouter({
prisma: db.prisma,
services: fake.services,
dataDir: path.join(mediaRoot, ".."),
});
rpc = (actor) => createRouterClient(router, { context: { actor } });
app = new Hono();
mountMediaHttpRoutes(
app,
{
services: fake.services,
...(options.chunkTimeoutMs ? { chunkTimeoutMs: options.chunkTimeoutMs } : {}),
},
async () => currentActor,
);
}
async function snapshot(spaceId: string) {
const p = db.prisma;
const where = { spaceId };
const orderBy = { id: "asc" as const };
return JSON.stringify(
[
await p.mediaSource.findMany({ where, orderBy }),
await p.mediaAsset.findMany({ where, orderBy }),
await p.mediaUpload.findMany({ where, orderBy }),
await p.mediaSegment.findMany({ where, orderBy }),
await p.mediaAnalysis.findMany({ where, orderBy }),
await p.mediaCollection.findMany({ where, orderBy }),
await p.mediaCollectionItem.findMany({
where: { collection: where },
orderBy: [{ collectionId: "asc" }, { assetId: "asc" }],
}),
await p.mediaPerson.findMany({ where, orderBy }),
await p.mediaFaceTrack.findMany({ where, orderBy }),
await p.mediaMark.findMany({ where, orderBy }),
await p.mediaBrandKit.findMany({ where, orderBy }),
await p.mediaProject.findMany({ where, orderBy }),
await p.mediaEditPlan.findMany({ where, orderBy }),
await p.mediaRender.findMany({ where, orderBy }),
await p.mediaPublication.findMany({ where, orderBy }),
await p.mediaMetric.findMany({ where, orderBy }),
await p.mediaMetricMapping.findMany({ where, orderBy }),
],
(_key, value) => (typeof value === "bigint" ? value.toString() : value),
);
}
async function artifactIn(space: Space): Promise<string> {
const artifact = await db.prisma.artifact.create({
data: {
spaceId: space.spaceId,
userId: space.userId,
name: "consent.pdf",
mimeType: "application/pdf",
size: 1,
hash: "0".repeat(64),
storageKey: `test/${space.spaceId}/consent.pdf`,
},
});
return artifact.id;
}
/** A device item in `space` that waits for its original. */
async function remoteAsset(space: Space, index: number): Promise<string> {
const source = await procedure(
rpc(actorOf(space)),
"sources/registerClient",
)({ kind: "ios_library", label: "Phone", deviceId: `device-${index}` });
const synced = await repos.upsertSourceItems(space.spaceId, (source as { id: string }).id, [
sourceItem(index),
]);
const assetId = synced.changedAssetIds[0] as string;
await repos.requestUpload(space.spaceId, [assetId]);
return assetId;
}
function putChunk(uploadId: string, index: number, body: BodyInit, actor: Actor | null) {
currentActor = actor;
return app.request(`/api/media/uploads/${uploadId}/chunks/${index}`, { method: "PUT", body });
}
beforeAll(async () => {
db = openMediaTestDb(databaseUrl!);
repos = createMediaRepos(db.prisma);
const root = await mkdtemp(path.join(tmpdir(), "media-api-pg-"));
mediaRoot = path.join(root, "media");
await mkdir(mediaRoot);
renderOutputs = new Map();
a = await db.newUser();
b = await db.newUser();
seededA = await seedMediaSpace(repos, a.spaceId, a.userId);
seededB = await seedMediaSpace(repos, b.spaceId, b.userId);
setup();
});
afterAll(async () => {
await db?.cleanup();
if (mediaRoot) await rm(path.dirname(mediaRoot), { recursive: true, force: true });
});
it("refuses another space's ids in every media procedure and leaves that space untouched", async () => {
const before = await snapshot(a.spaceId);
const foreignIds = Object.values(seededA);
const intruder = rpc(actorOf(b));
const inputs = sampleMediaInputs(seededA, testBrandKit);
for (const [rpcPath, sample] of Object.entries(inputs)) {
if (sample.foreign === "dedicated") continue;
const call = procedure(intruder, rpcPath)(sample.input);
if (sample.foreign === "not_found") {
expect(await rpcCode(call), rpcPath).toBe("NOT_FOUND");
continue;
}
const result = JSON.stringify(await call);
for (const id of foreignIds) expect(result, rpcPath).not.toContain(id);
}
expect(await snapshot(a.spaceId)).toBe(before);
// No service ever received the other space's ids.
const leaked = calls.filter((call) =>
foreignIds.some((id) => JSON.stringify(call.input ?? null).includes(id)),
);
expect(leaked).toEqual([]);
expect(calls.every((call) => call.actor.spaceId === b.spaceId)).toBe(true);
});
it("serves the same inputs inside the caller's own space", async () => {
// Positive control: NOT_FOUND above comes from isolation, not from bad samples.
const fabricated = new Set(["collections/merge", "people/merge"]);
for (const [rpcPath, sample] of Object.entries(sampleMediaInputs(seededA, testBrandKit))) {
if (sample.foreign !== "not_found" || fabricated.has(rpcPath)) continue;
const space = await db.newUser();
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
const input = sampleMediaInputs(seeded, testBrandKit)[rpcPath]?.input;
const code = await rpcCode(procedure(rpc(actorOf(space)), rpcPath)(input));
expect(code, rpcPath).not.toBe("NOT_FOUND");
}
}, 120_000);
it("refuses another space's records referenced from the caller's own ones", async () => {
const own = rpc(actorOf(b));
const foreignArtifact = await artifactIn(a);
const attempts: Array<[string, unknown]> = [
["projects/update", { projectId: seededB.projectId, collectionIds: [seededA.collectionId] }],
["projects/update", { projectId: seededB.projectId, brandKitId: seededA.brandKitId }],
[
"projects/create",
{
title: "Mixed",
goal: "teaser",
assetIds: [seededA.assetId],
profileIds: ["example-short-vertical"],
},
],
["people/assignTrack", { trackId: seededB.trackId, personId: seededA.personId }],
[
"marks/create",
{
kind: "tattoo",
assetId: seededB.assetId,
personId: seededA.personId,
prompt: { tMs: 0 },
treatment: "blur",
},
],
["marks/update", { markId: seededB.markId, personId: seededA.personId }],
[
"assets/setPaidOnly",
{ segmentIds: [seededB.segmentId, seededA.segmentId], paidOnly: true },
],
["collections/merge", { collectionIds: [seededB.collectionId, seededA.collectionId] }],
["people/merge", { personIds: [seededB.personId, seededA.personId] }],
["brandKits/upsert", { kit: { ...testBrandKit, logoArtifactIds: [foreignArtifact] } }],
[
"people/recordConsent",
{
personId: seededB.personId,
consentArtifactId: foreignArtifact,
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "document_on_file",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
},
],
];
const before = await snapshot(a.spaceId);
for (const [rpcPath, input] of attempts) {
expect(await rpcCode(procedure(own, rpcPath)(input)), rpcPath).toBe("NOT_FOUND");
}
expect(await snapshot(a.spaceId)).toBe(before);
const person = await db.prisma.mediaPerson.findUniqueOrThrow({
where: { id: seededB.personId },
});
expect(person.consentRecordedAt).toBeNull();
});
it("records consent only as the signed-in member and ignores it on updates", async () => {
const space = await db.newUser();
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
const client = rpc(actorOf(space));
await procedure(
client,
"people/update",
)({
personId: seeded.personId,
displayName: "Person 1",
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
});
let person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
expect(person.consentRecordedAt).toBeNull();
expect(person.ageVerifiedAt).toBeNull();
const artifactId = await artifactIn(space);
await procedure(
client,
"people/recordConsent",
)({
personId: seeded.personId,
consentArtifactId: artifactId,
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
});
person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
expect(person.consentRecordedBy).toBe(space.userId);
expect(person.consentArtifactId).toBe(artifactId);
expect(person.ageVerificationMethod).toBe("platform_verified");
});
it("assembles a resumable chunked upload and hands it to ingest", async () => {
const assetId = await remoteAsset(b, 101);
const bytes = new TextEncoder().encode("0123456789");
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: bytes.length,
sha256: sha256(bytes),
fileName: "clip.mp4",
chunkBytes: 4,
});
const owner = actorOf(b);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), actorOf(a))).status).toBe(404);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), null)).status).toBe(401);
expect((await putChunk(upload.id, 3, bytes.slice(0, 2), owner)).status).toBe(400);
expect((await putChunk(upload.id, 0, bytes.slice(0, 5), owner)).status).toBe(413);
expect((await putChunk(upload.id, 0, bytes.slice(0, 3), owner)).status).toBe(400);
// Out of order and repeated chunks are fine; the client resumes from status.
expect((await putChunk(upload.id, 2, bytes.slice(8), owner)).status).toBe(200);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
const client = rpc(owner);
const partial = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
receivedChunks: number[];
};
expect([...partial.receivedChunks].sort()).toEqual([0, 2]);
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
"CONFLICT",
);
expect((await putChunk(upload.id, 1, bytes.slice(4, 8), owner)).status).toBe(200);
calls.length = 0;
const done = (await procedure(client, "uploads/complete")({ uploadId: upload.id })) as {
status: string;
};
expect(done.status).toBe("complete");
const handed = calls.filter((call) => call.method === "uploadCompleted");
expect(handed).toHaveLength(1);
const input = handed[0]?.input as { tempPath: string; sha256: string; assetId: string };
expect(input.assetId).toBe(assetId);
expect(input.sha256).toBe(sha256(bytes));
expect(input.tempPath.startsWith(path.join(mediaRoot, b.spaceId, "uploads"))).toBe(true);
expect(await readFile(input.tempPath, "utf8")).toBe("0123456789");
const asset = await repos.getAssetRecord(b.spaceId, assetId);
expect(asset.storage).toBe("uploaded");
expect(asset.storagePath).toBe(`${b.spaceId}/uploads/${upload.id}/file`);
// Completing again is idempotent and does not hand the file over twice.
await procedure(client, "uploads/complete")({ uploadId: upload.id });
expect(calls.filter((call) => call.method === "uploadCompleted")).toHaveLength(1);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(409);
});
it("opens uploads through the RPC only for items that wait for their original", async () => {
const client = rpc(actorOf(b));
const assetId = await remoteAsset(b, 102);
const input = { assetId, sizeBytes: 10, sha256: "a".repeat(64), fileName: "clip.mp4" };
const first = (await procedure(client, "uploads/begin")(input)) as { id: string };
const resumed = (await procedure(client, "uploads/begin")(input)) as { id: string };
expect(resumed.id).toBe(first.id);
const referenced = { ...input, assetId: seededB.assetId };
expect(await rpcCode(procedure(client, "uploads/begin")(referenced))).toBe("CONFLICT");
const tooLarge = { ...input, sizeBytes: 21 * 1024 * 1024 * 1024 };
expect(await rpcCode(procedure(client, "uploads/begin")(tooLarge))).toBe("BAD_REQUEST");
});
it("fails an upload whose bytes do not match the announced checksum", async () => {
const assetId = await remoteAsset(b, 103);
const bytes = new TextEncoder().encode("abcdef");
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: bytes.length,
sha256: "f".repeat(64),
fileName: "clip.mp4",
chunkBytes: 4,
});
const owner = actorOf(b);
await putChunk(upload.id, 0, bytes.slice(0, 4), owner);
await putChunk(upload.id, 1, bytes.slice(4), owner);
const client = rpc(owner);
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
"BAD_REQUEST",
);
const status = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
status: string;
};
expect(status.status).toBe("failed");
const dir = path.join(mediaRoot, b.spaceId, "uploads", upload.id);
await expect(stat(dir)).rejects.toMatchObject({ code: "ENOENT" });
});
it("expires an idle upload and starts a fresh one", async () => {
const assetId = await remoteAsset(b, 104);
const input = { assetId, sizeBytes: 10, sha256: "b".repeat(64), fileName: "clip.mp4" };
const first = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
setup({ now: () => new Date(Date.now() + MEDIA_UPLOAD_IDLE_MS + 60_000) });
try {
const response = await putChunk(first.id, 0, new Uint8Array(10), actorOf(b));
expect(response.status).toBe(409);
const status = (await procedure(
rpc(actorOf(b)),
"uploads/status",
)({
uploadId: first.id,
})) as { status: string };
expect(status.status).toBe("failed");
const fresh = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
expect(fresh.id).not.toBe(first.id);
} finally {
setup();
}
});
it("drops a chunk that does not arrive in time", async () => {
const assetId = await remoteAsset(b, 105);
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: 10,
sha256: "c".repeat(64),
fileName: "clip.mp4",
});
setup({ chunkTimeoutMs: 50 });
try {
const stalled = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(2));
},
});
currentActor = actorOf(b);
const response = await app.request(`/api/media/uploads/${upload.id}/chunks/0`, {
method: "PUT",
body: stalled,
duplex: "half",
} as RequestInit);
expect(response.status).toBe(408);
const status = await repos.uploadStatus(b.spaceId, upload.id);
expect(status.receivedChunks).toEqual([]);
} finally {
setup();
}
});
it("streams render files only inside the caller's space and render folder", async () => {
const renderDir = path.join(mediaRoot, a.spaceId, "renders", seededA.renderId);
await mkdir(renderDir, { recursive: true });
const video = path.join(renderDir, "video.mp4");
await writeFile(video, "0123456789");
renderOutputs.set(seededA.renderId, video);
const url = `/api/media/renders/${seededA.renderId}/files/video.mp4`;
currentActor = actorOf(a);
const full = await app.request(url);
expect(full.status).toBe(200);
expect(await full.text()).toBe("0123456789");
const ranged = await app.request(url, { headers: { range: "bytes=2-4" } });
expect(ranged.status).toBe(206);
expect(ranged.headers.get("content-range")).toBe("bytes 2-4/10");
expect(await ranged.text()).toBe("234");
const download = await app.request(`${url}?download=1`);
expect(download.headers.get("content-disposition")).toContain("attachment");
// A render that has not passed its gate never leaves as a download.
const queuedVideo = path.join(mediaRoot, a.spaceId, "renders", seededA.queuedRenderId, "v.mp4");
await mkdir(path.dirname(queuedVideo), { recursive: true });
await writeFile(queuedVideo, "x");
renderOutputs.set(seededA.queuedRenderId, queuedVideo);
const queuedUrl = `/api/media/renders/${seededA.queuedRenderId}/files/v.mp4`;
expect((await app.request(queuedUrl)).status).toBe(200);
expect((await app.request(`${queuedUrl}?download=1`)).status).toBe(404);
// A path the service reports outside the render folder is refused.
const outside = path.join(mediaRoot, a.spaceId, "video.mp4");
await writeFile(outside, "secret");
renderOutputs.set(seededA.renderId, outside);
expect((await app.request(url)).status).toBe(404);
renderOutputs.set(seededA.renderId, video);
currentActor = actorOf(b);
expect((await app.request(url)).status).toBe(404);
expect((await app.request(`/api/media/assets/${seededA.assetId}/thumb`)).status).toBe(404);
expect((await app.request(`/api/media/bundles/${seededA.publicationId}`)).status).toBe(404);
});
it("refuses a folder source to members who do not own the deployment", async () => {
const folder = await mkdtemp(path.join(tmpdir(), "media-footage-"));
try {
const input = { label: "Footage", path: folder };
const member = procedure(rpc(actorOf(b)), "sources/createFolder")(input);
expect(await rpcCode(member)).toBe("FORBIDDEN");
const created = (await procedure(rpc(actorOf(b, true)), "sources/createFolder")(input)) as {
id: string;
kind: string;
};
expect(created.kind).toBe("folder");
const row = await db.prisma.mediaSource.findUniqueOrThrow({ where: { id: created.id } });
expect(row.ownerUserId).toBe(b.userId);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
});

333
apps/api/src/media.test.ts Normal file
View File

@ -0,0 +1,333 @@
import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRouterClient, ORPCError } from "@orpc/server";
import type { Actor } from "@rakazo/contracts";
import { MediaToolInputSchemas, mediaRpcShapes } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
import { InvalidMediaInputError, IsolationError, MediaStateConflictError } from "@rakazo/db";
import { PathOutsideRootError } from "@rakazo/media/node";
import { Hono } from "hono";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createMediaRouter,
MEDIA_NOT_CONFIGURED,
mediaRpcError,
unconfiguredCapabilities,
validateMediaFolder,
} from "./media.js";
import { mountMediaHttpRoutes, parseByteRange } from "./media-http.js";
import type { MediaSampleIds } from "./media-test-fakes.js";
import { fakeMediaServices, mediaProcedurePaths, sampleMediaInputs } from "./media-test-fakes.js";
import { expectedChunkBytes, uploadChunkCount } from "./media-uploads.js";
const member: Actor = {
userId: "user-member",
spaceId: "space-a",
email: "member@rakazo.test",
isDeploymentOwner: false,
};
const owner: Actor = { ...member, userId: "user-owner", isDeploymentOwner: true };
const ids = Object.fromEntries(
[
"sourceId",
"assetId",
"secondAssetId",
"segmentId",
"trackId",
"personId",
"markId",
"collectionId",
"brandKitId",
"projectId",
"planId",
"renderId",
"publicationId",
"mappingId",
"uploadId",
].map((key) => [key, `id-${key.toLowerCase()}`]),
) as unknown as MediaSampleIds;
const kit = {
name: "Kit",
colors: {
primary: "#101010",
secondary: "#202020",
accent: "#303030",
text: "#f0f0f0",
background: "#000000",
},
};
function client(router: ReturnType<typeof createMediaRouter>, actor: Actor | null) {
return createRouterClient(router, { context: { actor } });
}
function procedure(target: unknown, rpcPath: string) {
let node: unknown = target;
for (const key of rpcPath.split("/")) node = (node as Record<string, unknown>)[key];
return node as (input: unknown) => Promise<unknown>;
}
async function rpcCode(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof ORPCError) return error.code;
throw error;
}
return "OK";
}
/** Repositories that fail the test when reached. */
function untouchedRepos(): MediaRepos {
return new Proxy({} as MediaRepos, {
get: (_target, key) => () => {
throw new Error(`repository ${String(key)} must not be called`);
},
});
}
const prismaStub = {} as PrismaClient;
describe("media router without a configured sidecar", () => {
const router = createMediaRouter({ prisma: prismaStub, services: null, dataDir: "/data" });
const inputs = sampleMediaInputs(ids, kit);
it("has a sample input for every media procedure", () => {
expect(Object.keys(inputs).sort()).toEqual(mediaProcedurePaths().sort());
});
it("reports media as not configured so every surface keeps it hidden", async () => {
const capabilities = await client(router, member).capabilities({});
expect(capabilities).toEqual(unconfiguredCapabilities());
expect(capabilities.configured).toBe(false);
expect(
Object.values(capabilities.operations).every(
(op) => !(op as { available: boolean }).available,
),
).toBe(true);
});
it("refuses every other procedure", async () => {
for (const [rpcPath, sample] of Object.entries(inputs)) {
if (rpcPath === "capabilities") continue;
const promise = procedure(client(router, member), rpcPath)(sample.input);
await expect(promise, rpcPath).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
message: MEDIA_NOT_CONFIGURED,
});
}
});
it("requires a signed-in member for every procedure", async () => {
for (const [rpcPath, sample] of Object.entries(inputs)) {
const code = await rpcCode(procedure(client(router, null), rpcPath)(sample.input));
expect(code, rpcPath).toBe("UNAUTHORIZED");
}
});
it("answers the byte routes with 404 and anonymous callers with 401", async () => {
const app = new Hono();
let actor: Actor | null = member;
mountMediaHttpRoutes(app, { services: null }, async () => actor);
const requests = [
new Request("http://api/api/media/uploads/u1/chunks/0", { method: "PUT", body: "abcd" }),
new Request("http://api/api/media/renders/r1/files/video.mp4"),
new Request("http://api/api/media/assets/a1/thumb"),
new Request("http://api/api/media/assets/a1/proxy"),
new Request("http://api/api/media/bundles/p1"),
];
for (const request of requests) {
expect((await app.request(request.clone())).status, request.url).toBe(404);
}
actor = null;
for (const request of requests) {
expect((await app.request(request.clone())).status, request.url).toBe(401);
}
});
});
describe("media router permissions", () => {
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), "media-api-"));
});
afterAll(async () => {
await rm(root, { recursive: true, force: true });
});
it("lets only the deployment owner add a folder source", async () => {
const created: unknown[] = [];
const repos = new Proxy({} as MediaRepos, {
get: (_target, key) => {
if (key === "createSource") {
return async (_spaceId: string, input: unknown) => {
created.push(input);
return {
id: "src-1",
kind: "folder",
label: "Footage",
status: "idle",
assetCount: 0,
lastSyncedAt: null,
error: null,
createdAt: "2026-10-01T00:00:00.000Z",
};
};
}
return () => {
throw new Error(`repository ${String(key)} must not be called`);
};
},
});
const { services, calls } = fakeMediaServices({ repos, mediaRoot: path.join(root, "media") });
const footage = path.join(root, "footage");
await mkdir(footage);
const router = createMediaRouter({
prisma: prismaStub,
services,
dataDir: path.join(root, "data"),
appRoot: path.join(root, "app"),
});
const denied = client(router, member).sources.createFolder({ label: "Footage", path: footage });
await expect(denied).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(created).toEqual([]);
expect(calls).toEqual([]);
await client(router, owner).sources.createFolder({ label: "Footage", path: footage });
expect(created).toEqual([
{ kind: "folder", label: "Footage", ownerUserId: owner.userId, config: { path: footage } },
]);
expect(calls.map((call) => `${call.service}.${call.method}`)).toEqual(["ingest.requestSync"]);
});
it("accepts only a real, specific folder apart from evOo's own directories", async () => {
const dataDir = path.join(root, "data");
const appRoot = path.join(root, "app");
const folder = path.join(root, "clips");
await mkdir(path.join(dataDir, "media"), { recursive: true });
await mkdir(path.join(appRoot, "packages"), { recursive: true });
await mkdir(folder, { recursive: true });
await symlink(folder, path.join(root, "clips-link"));
const forbidden = { dataDir, appRoot };
await expect(validateMediaFolder(folder, forbidden)).resolves.toBe(folder);
const rejected = [
"clips",
path.join(root, "missing"),
path.join(root, "clips-link"),
"/",
dataDir,
path.join(dataDir, "media"),
root,
path.join(appRoot, "packages"),
];
for (const candidate of rejected) {
await expect(validateMediaFolder(candidate, forbidden), candidate).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
}
});
});
describe("consent stays with the signed-in user", () => {
it("never accepts consent or age fields from the agent's people tool", () => {
const schema = MediaToolInputSchemas.media_people;
expect(schema.shape.action.options).toEqual(["list", "update", "merge"]);
const parsed = schema.parse({
action: "update",
personId: "person-1",
consentArtifactId: "artifact-1",
consentRecordedAt: "2026-09-01T00:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
});
expect(Object.keys(parsed).sort()).toEqual(["action", "personId"]);
});
it("drops consent fields sent to people.update", async () => {
const patches: unknown[] = [];
const repos = new Proxy({} as MediaRepos, {
get: (_target, key) => {
if (key === "updatePerson") {
return async (_spaceId: string, _personId: string, patch: unknown) => {
patches.push(patch);
return {};
};
}
return () => {
throw new Error(`repository ${String(key)} must not be called`);
};
},
});
const { services } = fakeMediaServices({ repos, mediaRoot: "/unused" });
const router = createMediaRouter({ prisma: prismaStub, services, dataDir: "/data" });
await procedure(
client(router, member),
"people/update",
)({
personId: "person-1",
displayName: "Person 1",
consentRecordedAt: "2026-09-01T00:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
}).catch(() => undefined);
expect(patches).toHaveLength(1);
expect(Object.entries(patches[0] as object).filter(([, value]) => value !== undefined)).toEqual(
[["displayName", "Person 1"]],
);
});
it("marks recordConsent as the only user-only write and keeps it out of the services", () => {
const people = mediaRpcShapes.people as Record<string, unknown>;
expect(Object.keys(people)).toContain("recordConsent");
const { services } = fakeMediaServices({ repos: untouchedRepos(), mediaRoot: "/unused" });
const serviceMethods = (["ingest", "privacy", "render", "marketing"] as const).flatMap((name) =>
Object.keys(services[name] as unknown as Record<string, unknown>),
);
expect(serviceMethods.filter((method) => /consent|age/i.test(method))).toEqual([]);
});
});
describe("media error mapping", () => {
it("never reveals whether a foreign id exists", () => {
expect((mediaRpcError(new IsolationError()) as ORPCError<string, unknown>).code).toBe(
"NOT_FOUND",
);
expect(
(mediaRpcError(new PathOutsideRootError("../x")) as ORPCError<string, unknown>).code,
).toBe("BAD_REQUEST");
expect(
(mediaRpcError(new MediaStateConflictError("busy")) as ORPCError<string, unknown>).code,
).toBe("CONFLICT");
const other = new Error("boom");
expect(mediaRpcError(other)).toBe(other);
});
});
describe("upload and streaming helpers", () => {
it("splits uploads into full chunks plus a shorter last one", () => {
const row = { sizeBytes: 10n, chunkBytes: 4 };
expect(uploadChunkCount(row)).toBe(3);
expect([0, 1, 2].map((index) => expectedChunkBytes(row, index))).toEqual([4, 4, 2]);
expect(uploadChunkCount({ sizeBytes: 8n, chunkBytes: 4 })).toBe(2);
expect(expectedChunkBytes({ sizeBytes: 8n, chunkBytes: 4 }, 1)).toBe(4);
});
it("parses single byte ranges and refuses unsatisfiable ones", () => {
expect(parseByteRange(undefined, 100)).toBeNull();
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=50-500", 100)).toEqual({ start: 50, end: 99 });
expect(parseByteRange("bytes=0-1,5-6", 100)).toBeNull();
expect(parseByteRange("bytes=100-", 100)).toBe("unsatisfiable");
expect(parseByteRange("bytes=9-3", 100)).toBe("unsatisfiable");
expect(parseByteRange("items=0-1", 100)).toBe("unsatisfiable");
expect(parseByteRange("bytes=-0", 100)).toBe("unsatisfiable");
});
});

552
apps/api/src/media.ts Normal file
View File

@ -0,0 +1,552 @@
import { lstat, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { implement, ORPCError } from "@orpc/server";
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
import type { MediaServices } from "@rakazo/adapters";
import { recordMetric, resetTeaserModel, saveBrandKit, segmentFunnel } from "@rakazo/adapters";
import type { Actor, BrandKit, MediaCapabilities, MediaSourceKind } from "@rakazo/contracts";
import { appContract, MediaInferenceOperationSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
InvalidMediaInputError,
InvalidProjectWorkspacePathError,
IsolationError,
lockMediaKey,
MediaStateConflictError,
mapMediaSource,
resolveProjectWorkspaceDirectory,
} from "@rakazo/db";
import { summarizeProfile } from "@rakazo/media";
import { PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import { beginMediaUpload, completeMediaUpload, UploadChunkError } from "./media-uploads.js";
export interface MediaRouterDeps {
prisma: PrismaClient;
/** Null when Media Studio is not configured: everything but capabilities refuses. */
services?: MediaServices | null;
/** DATA_DIR; a folder source may neither sit inside it nor contain it. */
dataDir: string;
/** Application install directory a folder source may not point into. */
appRoot?: string;
}
export const MEDIA_NOT_CONFIGURED = "Media Studio is not configured";
const DEFAULT_APP_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
/** Translate domain errors into RPC errors; everything else passes through. */
export function mediaRpcError(error: unknown): unknown {
if (error instanceof ORPCError) return error;
if (error instanceof IsolationError) return new ORPCError("NOT_FOUND");
if (
error instanceof InvalidMediaInputError ||
error instanceof InvalidProjectWorkspacePathError
) {
return new ORPCError("BAD_REQUEST", { message: error.message });
}
if (error instanceof PathOutsideRootError) {
return new ORPCError("BAD_REQUEST", { message: "That path is not allowed." });
}
if (error instanceof MediaStateConflictError) {
return new ORPCError("CONFLICT", { message: error.message });
}
if (error instanceof UploadChunkError) {
return new ORPCError(error.status === 409 ? "CONFLICT" : "BAD_REQUEST", {
message: error.message,
});
}
if (error instanceof UnsupportedMediaOperationError) {
return new ORPCError("PRECONDITION_FAILED", { message: error.message });
}
return error;
}
export function unconfiguredCapabilities(): MediaCapabilities {
const unavailable = {
available: false,
experimental: false,
model: null,
license: null,
commercialUse: null,
};
return {
configured: false,
operations: Object.fromEntries(
MediaInferenceOperationSchema.options.map((operation) => [operation, unavailable]),
) as MediaCapabilities["operations"],
accelerator: "none",
hardwareEncoders: [],
};
}
function overlaps(a: string, b: string): boolean {
const inside = (root: string, candidate: string) => {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
};
return inside(a, b) || inside(b, a);
}
async function realOrResolved(target: string): Promise<string> {
return realpath(target).catch(() => path.resolve(target));
}
/**
* A host folder for a folder source: absolute, an existing directory, not a
* symlink, and apart from DATA_DIR and the application itself. Returns the real path.
*/
export async function validateMediaFolder(
input: string,
forbidden: { dataDir: string; appRoot: string },
): Promise<string> {
const trimmed = input.trim();
if (!path.isAbsolute(trimmed)) {
throw new InvalidMediaInputError("Use an absolute folder path.");
}
const info = await lstat(trimmed).catch(() => null);
if (!info) throw new InvalidMediaInputError("That folder does not exist or is not readable.");
if (info.isSymbolicLink())
throw new InvalidMediaInputError("Choose the folder, not a link to it.");
const resolved = await resolveProjectWorkspaceDirectory(trimmed);
if (path.parse(resolved).root === resolved) {
throw new InvalidMediaInputError("Choose a specific folder.");
}
const dataDir = await realOrResolved(forbidden.dataDir);
const appRoot = await realOrResolved(forbidden.appRoot);
const relativeToApp = path.relative(appRoot, resolved);
const insideApp =
relativeToApp === "" || (!relativeToApp.startsWith("..") && !path.isAbsolute(relativeToApp));
if (overlaps(dataDir, resolved) || insideApp) {
throw new InvalidMediaInputError("That folder belongs to evOo itself.");
}
return resolved;
}
/** Artifacts referenced from media records must belong to the caller's space. */
export async function assertSpaceArtifacts(
prisma: PrismaClient,
spaceId: string,
artifactIds: string[],
): Promise<void> {
const ids = [...new Set(artifactIds)];
if (ids.length === 0) return;
const owned = await prisma.artifact.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
}
function brandKitArtifactIds(kit: BrandKit): string[] {
return [
...kit.fonts.map((font) => font.artifactId),
...kit.logoArtifactIds,
...(kit.lutArtifactId ? [kit.lutArtifactId] : []),
...kit.musicBeds.map((bed) => bed.artifactId),
];
}
async function removeMediaPath(mediaRoot: string, relative: string | null): Promise<void> {
if (!relative) return;
await rm(await resolveInside(mediaRoot, relative), { recursive: true, force: true });
}
export function createMediaRouter(deps: MediaRouterDeps) {
const os = implement(appContract.media).$context<{ actor: Actor | null; signal?: AbortSignal }>();
const authed = os.use(async ({ context, next }) => {
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
try {
return await next({ context: { ...context, actor: context.actor } });
} catch (error) {
throw mediaRpcError(error);
}
});
const media = authed.use(async ({ next }) => {
if (!deps.services) {
throw new ORPCError("PRECONDITION_FAILED", { message: MEDIA_NOT_CONFIGURED });
}
return next({ context: { services: deps.services } });
});
const forbiddenRoots = { dataDir: deps.dataDir, appRoot: deps.appRoot ?? DEFAULT_APP_ROOT };
async function knownProfiles(services: MediaServices, profileIds: string[] | undefined) {
if (!profileIds) return;
const { profiles } = await services.deps.profiles();
const unknown = profileIds.filter((id) => !profiles.has(id));
if (unknown.length > 0) {
throw new InvalidMediaInputError(`Unknown profile: ${unknown.join(", ")}`);
}
}
return os.router({
capabilities: authed.capabilities.handler(async ({ context }) => {
const services = deps.services;
if (!services) return unconfiguredCapabilities();
const hardwareEncoders = await services.deps.renderer.hardwareEncoders().catch(() => []);
const inference = services.deps.inference;
if (!inference) return { ...unconfiguredCapabilities(), hardwareEncoders };
const capabilities = await inference
.capabilities(context.signal)
.catch(() => ({ ...unconfiguredCapabilities(), configured: true }));
return { ...capabilities, configured: true, hardwareEncoders };
}),
sources: {
list: media.sources.list.handler(({ context }) =>
context.services.deps.repos.listSources(context.actor.spaceId),
),
createFolder: media.sources.createFolder.handler(async ({ context, input }) => {
// A folder lives on the host, and the host is the deployment owner's machine.
if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
const folder = await validateMediaFolder(input.path, forbiddenRoots);
const { repos } = context.services.deps;
const source = await repos.createSource(context.actor.spaceId, {
kind: "folder",
label: input.label,
ownerUserId: context.actor.userId,
config: { path: folder },
});
await context.services.ingest.requestSync(context.actor, source.id);
return source;
}),
registerClient: media.sources.registerClient.handler(async ({ context, input }) => {
const { spaceId, userId } = context.actor;
const kind: MediaSourceKind = input.kind;
// One source per device: re-registering returns the existing one.
return deps.prisma.$transaction(async (tx) => {
await lockMediaKey(tx, `source:${spaceId}:${kind}:${input.deviceId}`);
const existing = await tx.mediaSource.findFirst({
where: { spaceId, kind, config: { path: ["deviceId"], equals: input.deviceId } },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
include: { _count: { select: { assets: true } } },
});
if (existing) return mapMediaSource(existing, existing._count.assets);
const created = await tx.mediaSource.create({
data: {
spaceId,
kind,
label: input.label,
ownerUserId: userId,
config: { deviceId: input.deviceId },
},
});
return mapMediaSource(created, 0);
});
}),
pushItems: media.sources.pushItems.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
return context.services.ingest.pushItems(context.actor, input);
}),
sync: media.sources.sync.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
await context.services.ingest.requestSync(context.actor, input.sourceId);
return { ok: true as const };
}),
remove: media.sources.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
await context.services.ingest.removeSource(context.actor, input.sourceId);
return { ok: true as const };
}),
},
assets: {
search: media.assets.search.handler(({ context, input }) =>
context.services.deps.repos.searchAssets(context.actor.spaceId, input),
),
get: media.assets.get.handler(({ context, input }) =>
context.services.deps.repos.getAsset(context.actor.spaceId, input.assetId),
),
setPaidOnly: media.assets.setPaidOnly.handler(async ({ context, input }) => {
await context.services.deps.repos.setSegmentsPaidOnly(
context.actor.spaceId,
input.segmentIds,
input.paidOnly,
);
return { ok: true as const };
}),
requestUpload: media.assets.requestUpload.handler(async ({ context, input }) => ({
awaiting: await context.services.deps.repos.requestUpload(
context.actor.spaceId,
input.assetIds,
),
})),
},
uploads: {
begin: media.uploads.begin.handler(({ context, input }) =>
beginMediaUpload(context.services, context.actor, input),
),
status: media.uploads.status.handler(({ context, input }) =>
context.services.deps.repos.uploadStatus(context.actor.spaceId, input.uploadId),
),
complete: media.uploads.complete.handler(({ context, input }) =>
completeMediaUpload(context.services, context.actor, input.uploadId),
),
},
collections: {
list: media.collections.list.handler(({ context, input }) =>
context.services.deps.repos.listCollections(context.actor.spaceId, input),
),
get: media.collections.get.handler(({ context, input }) =>
context.services.deps.repos.getCollection(context.actor.spaceId, input.collectionId),
),
rename: media.collections.rename.handler(({ context, input }) =>
context.services.deps.repos.renameCollection(
context.actor.spaceId,
input.collectionId,
input.title,
),
),
merge: media.collections.merge.handler(({ context, input }) =>
context.services.deps.repos.mergeCollections(context.actor.spaceId, input.collectionIds),
),
split: media.collections.split.handler(({ context, input }) =>
context.services.deps.repos.splitCollection(
context.actor.spaceId,
input.collectionId,
input.atAssetId,
),
),
createManual: media.collections.createManual.handler(({ context, input }) =>
context.services.deps.repos.createManualCollection(
context.actor.spaceId,
input.title,
input.assetIds,
),
),
recluster: media.collections.recluster.handler(async ({ context }) => {
await context.services.ingest.recluster(context.actor);
return { ok: true as const };
}),
},
people: {
list: media.people.list.handler(({ context }) =>
context.services.deps.repos.listPeople(context.actor.spaceId),
),
update: media.people.update.handler(({ context, input }) =>
context.services.deps.repos.updatePerson(context.actor.spaceId, input.personId, {
displayName: input.displayName,
isSelf: input.isSelf,
confirmed: input.confirmed,
anonymizeByDefault: input.anonymizeByDefault,
}),
),
merge: media.people.merge.handler(({ context, input }) =>
context.services.deps.repos.mergePeople(context.actor.spaceId, input.personIds),
),
assignTrack: media.people.assignTrack.handler(({ context, input }) =>
context.services.deps.repos.assignTrack(
context.actor.spaceId,
input.trackId,
input.personId,
),
),
// The only consent write path: the signed-in member is the recorder.
recordConsent: media.people.recordConsent.handler(async ({ context, input }) => {
await assertSpaceArtifacts(
deps.prisma,
context.actor.spaceId,
input.consentArtifactId ? [input.consentArtifactId] : [],
);
return context.services.deps.repos.recordConsent(
{ spaceId: context.actor.spaceId, recordedByUserId: context.actor.userId },
input,
);
}),
remove: media.people.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.getPerson(context.actor.spaceId, input.personId);
await context.services.privacy.removePerson(context.actor, input.personId);
return { ok: true as const };
}),
},
marks: {
list: media.marks.list.handler(({ context, input }) =>
context.services.deps.repos.listMarks(context.actor.spaceId, {
...(input.personId ? { personId: input.personId } : {}),
...(input.assetId ? { assetId: input.assetId } : {}),
}),
),
create: media.marks.create.handler(async ({ context, input }) => {
const { repos } = context.services.deps;
await repos.getAssetRecord(context.actor.spaceId, input.assetId);
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
return context.services.privacy.createMark(context.actor, input);
}),
update: media.marks.update.handler(async ({ context, input }) => {
const { repos } = context.services.deps;
await repos.getMark(context.actor.spaceId, input.markId);
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
return context.services.privacy.updateMark(context.actor, input);
}),
remove: media.marks.remove.handler(async ({ context, input }) => {
const { repos, mediaRoot } = context.services.deps;
const { maskDir } = await repos.removeMark(context.actor.spaceId, input.markId);
await removeMediaPath(mediaRoot, maskDir);
return { ok: true as const };
}),
},
brandKits: {
list: media.brandKits.list.handler(({ context }) =>
context.services.deps.repos.listBrandKits(context.actor.spaceId),
),
upsert: media.brandKits.upsert.handler(async ({ context, input }) => {
await assertSpaceArtifacts(
deps.prisma,
context.actor.spaceId,
brandKitArtifactIds(input.kit),
);
return saveBrandKit(context.services.deps, context.actor, input);
}),
remove: media.brandKits.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.removeBrandKit(context.actor.spaceId, input.id);
return { ok: true as const };
}),
templates: media.brandKits.templates.handler(({ context }) =>
context.services.deps.motion.templates(),
),
introVariants: media.brandKits.introVariants.handler(async ({ context, input }) => {
await context.services.deps.repos.getBrandKit(context.actor.spaceId, input.id);
return context.services.marketing.introVariants(context.actor, {
brandKitId: input.id,
count: input.count,
});
}),
},
profiles: {
list: media.profiles.list.handler(async ({ context }) => {
const { profiles } = await context.services.deps.profiles();
const now = context.services.deps.now();
return [...profiles.values()].map(({ profile, origin }) =>
summarizeProfile(profile, origin, now),
);
}),
get: media.profiles.get.handler(async ({ context, input }) => {
const { profiles } = await context.services.deps.profiles();
const entry = profiles.get(input.profileId);
if (!entry) throw new ORPCError("NOT_FOUND");
return entry.profile;
}),
},
projects: {
list: media.projects.list.handler(({ context, input }) =>
context.services.deps.repos.listProjects(context.actor.spaceId, input),
),
get: media.projects.get.handler(({ context, input }) =>
context.services.deps.repos.getProject(context.actor.spaceId, input.projectId),
),
create: media.projects.create.handler(async ({ context, input }) => {
await knownProfiles(context.services, input.profileIds);
return context.services.deps.repos.createProject(
context.actor.spaceId,
context.actor.userId,
input,
);
}),
update: media.projects.update.handler(async ({ context, input }) => {
await knownProfiles(context.services, input.profileIds);
const { projectId, ...patch } = input;
return context.services.deps.repos.updateProject(context.actor.spaceId, projectId, patch);
}),
remove: media.projects.remove.handler(async ({ context, input }) => {
const { repos, mediaRoot } = context.services.deps;
const { spaceId } = context.actor;
await repos.getProjectRecord(spaceId, input.projectId);
const renders = await repos.listRenders(spaceId, input.projectId);
await repos.removeProject(spaceId, input.projectId);
for (const render of renders)
await removeMediaPath(mediaRoot, `${spaceId}/renders/${render.id}`);
return { ok: true as const };
}),
autoPlan: media.projects.autoPlan.handler(async ({ context, input }) => {
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
await knownProfiles(context.services, input.profileIds);
return context.services.marketing.autoPlan(context.actor, input);
}),
},
plans: {
submit: media.plans.submit.handler(async ({ context, input }) => {
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
return context.services.marketing.submitPlan(context.actor, {
projectId: input.projectId,
edl: input.edl,
rationale: input.rationale,
createdBy: "user",
});
}),
},
renders: {
start: media.renders.start.handler(async ({ context, input }) => {
await context.services.deps.repos.getPlan(context.actor.spaceId, input.planId);
return context.services.render.start(context.actor, input);
}),
get: media.renders.get.handler(({ context, input }) =>
context.services.deps.repos.getRender(context.actor.spaceId, input.renderId),
),
cancel: media.renders.cancel.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.render.cancel(context.actor, input.renderId);
}),
thumbnails: media.renders.thumbnails.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.thumbnails(context.actor, input);
}),
},
marketing: {
copy: media.marketing.copy.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.copy(context.actor, input);
}),
},
publications: {
list: media.publications.list.handler(({ context, input }) =>
context.services.deps.repos.listPublications(context.actor.spaceId, input),
),
create: media.publications.create.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.createPublication(context.actor, input);
}),
bundle: media.publications.bundle.handler(async ({ context, input }) => {
await context.services.deps.repos.getPublication(
context.actor.spaceId,
input.publicationId,
);
return context.services.marketing.bundle(context.actor, input.publicationId);
}),
markPublished: media.publications.markPublished.handler(({ context, input }) =>
context.services.deps.repos.markPublicationPublished(
context.actor.spaceId,
input.publicationId,
input.externalUrl,
context.services.deps.now(),
),
),
cancel: media.publications.cancel.handler(async ({ context, input }) => {
await context.services.deps.repos.getPublication(
context.actor.spaceId,
input.publicationId,
);
return context.services.marketing.cancelPublication(context.actor, input.publicationId);
}),
},
metrics: {
mappings: media.metrics.mappings.handler(({ context }) =>
context.services.deps.repos.listMetricMappings(context.actor.spaceId),
),
saveMapping: media.metrics.saveMapping.handler(({ context, input }) =>
context.services.deps.repos.saveMetricMapping(context.actor.spaceId, input),
),
importCsv: media.metrics.importCsv.handler(async ({ context, input }) => {
await context.services.deps.repos.getMetricMapping(context.actor.spaceId, input.mappingId);
return context.services.marketing.importMetricsCsv(context.actor, input);
}),
record: media.metrics.record.handler(({ context, input }) =>
recordMetric(context.services.deps, context.actor, input),
),
funnel: media.metrics.funnel.handler(({ context, input }) =>
context.services.deps.repos.funnel(context.actor.spaceId, input.projectId),
),
segments: media.metrics.segments.handler(({ context, input }) =>
segmentFunnel(context.services.deps, context.actor, input.projectId),
),
resetTeaserModel: media.metrics.resetTeaserModel.handler(async ({ context }) => {
await resetTeaserModel(context.services.deps, context.actor);
return { ok: true as const };
}),
},
});
}

View File

@ -24,6 +24,7 @@ import type {
ConnectorRegistry,
EncryptedSecretStore,
IntegrationProviderSettings,
MediaServices,
MemoryProviderResolver,
PiOAuthLogins,
RemoteConnectorDependencies,
@ -166,6 +167,7 @@ import {
renameMainConversation,
} from "./main-conversation.js";
import { buildMcpUpdateMaterial } from "./mcp-material.js";
import { createMediaRouter } from "./media.js";
import {
disconnectMemoryProvider,
getMemoryProviderConfig,
@ -463,6 +465,8 @@ export interface RouterDeps {
remoteConnectors?: RemoteConnectorDependencies;
artifacts: ArtifactStore;
dataDir: string;
/** Null when no media sidecar (or scripted emulator) is configured; Media Studio then stays hidden. */
mediaServices?: MediaServices | null;
/** Present when the external messaging surface is enabled. */
messaging?: { enabled: boolean; providers: string[]; openSignup: boolean };
env: {
@ -526,6 +530,11 @@ export function createRouter(deps: RouterDeps) {
});
return os.router({
media: createMediaRouter({
prisma: deps.prisma,
services: deps.mediaServices ?? null,
dataDir: deps.dataDir,
}),
aiConsent: {
status: authed.aiConsent.status.handler(({ context, input }) =>
aiConsentStatus(deps, context.actor, input),

View File

@ -0,0 +1,2 @@
.build/
.swiftpm/

View File

@ -0,0 +1,18 @@
// swift-tools-version:5.9
import PackageDescription
// PhotosHelperCore holds everything that does not touch PhotoKit (argument parsing,
// cursors, JSON envelopes, the export path guard, hashing) so XCTest runs without
// Photos access. The executable target is the thin PhotoKit layer.
let package = Package(
name: "photos-helper",
platforms: [.macOS(.v13)],
products: [
.executable(name: "photos-helper", targets: ["photos-helper"])
],
targets: [
.target(name: "PhotosHelperCore"),
.executableTarget(name: "photos-helper", dependencies: ["PhotosHelperCore"]),
.testTarget(name: "PhotosHelperCoreTests", dependencies: ["PhotosHelperCore"]),
]
)

View File

@ -0,0 +1,142 @@
import Foundation
public enum ExportVariant: String, Equatable {
/// The untouched original resource, downloaded from iCloud when needed.
case original
/// A smaller rendition of the current version (720p video, 1280 px JPEG).
case proxy
}
public enum Command: Equatable {
/// `prompt: false` only reads the current status and never shows the system dialog.
case authorize(prompt: Bool)
case list(cursor: ListCursor?, limit: Int, since: Date?)
case asset(localIdentifier: String)
case export(localIdentifier: String, destination: String, variant: ExportVariant)
case changes(token: String)
}
public let usageText = """
usage: photos-helper authorize [--no-prompt]
photos-helper list [--cursor <opaque>] [--limit <1-1000>] [--since <iso8601>]
photos-helper asset <localIdentifier>
photos-helper export <localIdentifier> --dest <dir> [--original|--proxy]
photos-helper changes --token <opaque>
"""
/// Parses argv without the program name. Unknown flags, repeated flags and missing
/// values are usage errors so a typo never silently changes behaviour.
public func parseArguments(_ arguments: [String]) throws -> Command {
guard let name = arguments.first else { throw HelperError(.usage, usageText) }
var options = try OptionReader(Array(arguments.dropFirst()))
switch name {
case "authorize":
let noPrompt = options.flag("--no-prompt")
try options.finish(positionals: 0)
return .authorize(prompt: !noPrompt)
case "list":
let cursor = try options.value("--cursor").map(ListCursor.decode)
let limit = try options.value("--limit").map(parseLimit) ?? Limits.defaultPage
let since = try options.value("--since").map { value -> Date in
guard let date = parseTimestamp(value) else {
throw HelperError(.usage, "--since must be an ISO 8601 timestamp.")
}
return date
}
try options.finish(positionals: 0)
return .list(cursor: cursor, limit: limit, since: since)
case "asset":
try options.finish(positionals: 1)
let identifier = try validIdentifier(options.positionals[0])
return .asset(localIdentifier: identifier)
case "export":
guard let destination = options.value("--dest") else {
throw HelperError(.usage, "export needs --dest <dir>.")
}
let original = options.flag("--original")
let proxy = options.flag("--proxy")
if original && proxy { throw HelperError(.usage, "Choose --original or --proxy, not both.") }
try options.finish(positionals: 1)
let identifier = try validIdentifier(options.positionals[0])
return .export(
localIdentifier: identifier,
destination: destination,
variant: proxy ? .proxy : .original)
case "changes":
guard let token = options.value("--token"), !token.isEmpty else {
throw HelperError(.usage, "changes needs --token <opaque>.")
}
try options.finish(positionals: 0)
return .changes(token: token)
default:
throw HelperError(.usage, usageText)
}
}
func parseLimit(_ value: String) throws -> Int {
guard let limit = Int(value), (1...Limits.maxPage).contains(limit) else {
throw HelperError(.usage, "--limit must be between 1 and \(Limits.maxPage).")
}
return limit
}
func validIdentifier(_ value: String) throws -> String {
guard !value.isEmpty, value.utf16.count <= Limits.externalId, !value.hasPrefix("-"),
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
else { throw HelperError(.usage, "Invalid local identifier.") }
return value
}
private struct OptionReader {
private var values: [String: String] = [:]
private var flags: Set<String> = []
private var consumed: Set<String> = []
private(set) var positionals: [String] = []
private static let valueOptions: Set<String> = ["--cursor", "--limit", "--since", "--dest", "--token"]
private static let flagOptions: Set<String> = ["--no-prompt", "--original", "--proxy"]
init(_ arguments: [String]) throws {
var index = 0
while index < arguments.count {
let argument = arguments[index]
if Self.valueOptions.contains(argument) {
guard index + 1 < arguments.count else {
throw HelperError(.usage, "\(argument) needs a value.")
}
guard values[argument] == nil else { throw HelperError(.usage, "\(argument) given twice.") }
values[argument] = arguments[index + 1]
index += 2
} else if Self.flagOptions.contains(argument) {
guard !flags.contains(argument) else { throw HelperError(.usage, "\(argument) given twice.") }
flags.insert(argument)
index += 1
} else if argument.hasPrefix("--") {
throw HelperError(.usage, "Unknown option \(argument).")
} else {
positionals.append(argument)
index += 1
}
}
}
mutating func value(_ name: String) -> String? {
consumed.insert(name)
return values[name]
}
mutating func flag(_ name: String) -> Bool {
consumed.insert(name)
return flags.contains(name)
}
/// Rejects options that belong to another command and surplus positionals.
func finish(positionals expected: Int) throws {
for name in Set(values.keys).union(flags) where !consumed.contains(name) {
throw HelperError(.usage, "\(name) is not valid here.")
}
guard positionals.count == expected else {
throw HelperError(.usage, expected == 0 ? "Unexpected argument." : "Expected \(expected) argument.")
}
}
}

View File

@ -0,0 +1,37 @@
import Foundation
/// A failure the desktop app maps by `code`. Messages never contain library content
/// beyond the identifier the caller passed in.
public struct HelperError: Error, Equatable {
public enum Code: String, Codable, CaseIterable {
case usage
case notAuthorized = "not_authorized"
case notFound = "not_found"
case pathRejected = "path_rejected"
case destinationExists = "destination_exists"
case network
case exportFailed = "export_failed"
case `internal`
}
public let code: Code
public let message: String
public init(_ code: Code, _ message: String) {
self.code = code
self.message = message
}
/// sysexits(3) style codes so a shell user can tell failure classes apart.
public var exitCode: Int32 {
switch code {
case .usage: return 64
case .notFound: return 66
case .pathRejected, .destinationExists: return 73
case .exportFailed: return 74
case .network: return 75
case .notAuthorized: return 77
case .internal: return 70
}
}
}

View File

@ -0,0 +1,249 @@
import Foundation
/// Mirrors `ClientMediaItemSchema` in packages/contracts/src/media.ts. Every key is
/// always present (nullable fields encode as `null`) because the schema has no optional keys.
public struct ClientMediaItem: Encodable, Equatable {
public enum Kind: String, Encodable {
case video
case photo
case livePhoto = "live_photo"
}
public var externalId: String
public var kind: Kind
public var capturedAt: String?
public var durationMs: Int?
public var width: Int?
public var height: Int?
public var fps: Double?
public var gpsLat: Double?
public var gpsLon: Double?
public var deviceModel: String?
public var albumNames: [String]
public var isFavorite: Bool
public var fileName: String?
public init(
externalId: String,
kind: Kind,
capturedAt: Date?,
durationSeconds: Double?,
width: Int?,
height: Int?,
latitude: Double?,
longitude: Double?,
albumNames: [String],
isFavorite: Bool,
fileName: String?
) {
self.externalId = externalId
self.kind = kind
self.capturedAt = capturedAt.map(formatTimestamp)
if let durationSeconds, durationSeconds.isFinite, durationSeconds > 0 {
durationMs = Int((durationSeconds * 1000).rounded())
} else {
durationMs = nil
}
self.width = width.flatMap { $0 > 0 ? $0 : nil }
self.height = height.flatMap { $0 > 0 ? $0 : nil }
// PhotoKit exposes neither frame rate nor camera model without loading the
// file; the server reads both with ffprobe after upload.
fps = nil
deviceModel = nil
let coordinate = roundedCoordinate(latitude: latitude, longitude: longitude)
gpsLat = coordinate?.latitude
gpsLon = coordinate?.longitude
self.albumNames = Array(
albumNames.map { clip($0, maxCharacters: Limits.albumName) }
.filter { !$0.isEmpty }
.prefix(Limits.albumCount))
self.isFavorite = isFavorite
self.fileName = fileName.map { clip($0, maxCharacters: Limits.fileName) }
}
enum CodingKeys: String, CodingKey {
case externalId, kind, capturedAt, durationMs, width, height, fps, gpsLat, gpsLon
case deviceModel, albumNames, isFavorite, fileName
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(externalId, forKey: .externalId)
try container.encode(kind, forKey: .kind)
// `encode` on an Optional writes `null`, unlike the synthesized `encodeIfPresent`.
try container.encode(capturedAt, forKey: .capturedAt)
try container.encode(durationMs, forKey: .durationMs)
try container.encode(width, forKey: .width)
try container.encode(height, forKey: .height)
try container.encode(fps, forKey: .fps)
try container.encode(gpsLat, forKey: .gpsLat)
try container.encode(gpsLon, forKey: .gpsLon)
try container.encode(deviceModel, forKey: .deviceModel)
try container.encode(albumNames, forKey: .albumNames)
try container.encode(isFavorite, forKey: .isFavorite)
try container.encode(fileName, forKey: .fileName)
}
}
/// Bounds from the contract schema, applied here so one odd asset cannot make a whole
/// page fail validation in the app.
public enum Limits {
public static let externalId = 200
public static let albumName = 200
public static let albumCount = 50
public static let fileName = 255
public static let defaultPage = 500
public static let maxPage = 1000
/// Above this many changed assets a full relisting is cheaper than one huge document.
public static let maxChanges = 5000
}
/// Clips to `maxCharacters` UTF-16 units (what zod's `.max()` counts) without splitting
/// a character.
public func clip(_ value: String, maxCharacters: Int) -> String {
if value.utf16.count <= maxCharacters { return value }
var result = ""
var units = 0
for character in value {
units += character.utf16.count
if units > maxCharacters { break }
result.append(character)
}
return result
}
/// Two decimals is about 1 km, matching `roundCoordinate` in @rakazo/media. Precise
/// locations never leave the device.
public func roundedCoordinate(latitude: Double?, longitude: Double?) -> (
latitude: Double, longitude: Double
)? {
guard let latitude, let longitude, latitude.isFinite, longitude.isFinite,
(-90...90).contains(latitude), (-180...180).contains(longitude),
!(latitude == 0 && longitude == 0)
else { return nil }
return ((latitude * 100).rounded() / 100, (longitude * 100).rounded() / 100)
}
/// RFC 3339 in UTC with milliseconds, accepted by zod's `datetime({ offset: true })`.
public func formatTimestamp(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(identifier: "UTC")
return formatter.string(from: date)
}
public func parseTimestamp(_ value: String) -> Date? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: value) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: value)
}
public enum AuthorizationState: String, Encodable {
case authorized, limited, denied, restricted, notDetermined
}
public struct AuthorizationOutput: Encodable, Equatable {
public let status: AuthorizationState
public init(status: AuthorizationState) { self.status = status }
}
public struct ListOutput: Encodable {
public let items: [ClientMediaItem]
public let nextCursor: String?
/// Library change token captured before enumeration; `changes --token` resumes from it.
public let changeToken: String?
public init(items: [ClientMediaItem], nextCursor: String?, changeToken: String?) {
self.items = items
self.nextCursor = nextCursor
self.changeToken = changeToken
}
enum CodingKeys: String, CodingKey { case items, nextCursor, changeToken }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(items, forKey: .items)
try container.encode(nextCursor, forKey: .nextCursor)
try container.encode(changeToken, forKey: .changeToken)
}
}
public struct AssetResourceInfo: Encodable, Equatable {
public let type: String
public let fileName: String
public let uti: String
public init(type: String, fileName: String, uti: String) {
self.type = type
self.fileName = fileName
self.uti = uti
}
}
public struct AssetOutput: Encodable {
public let item: ClientMediaItem
public let modifiedAt: String?
public let hidden: Bool
public let resources: [AssetResourceInfo]
public init(item: ClientMediaItem, modifiedAt: Date?, hidden: Bool, resources: [AssetResourceInfo]) {
self.item = item
self.modifiedAt = modifiedAt.map(formatTimestamp)
self.hidden = hidden
self.resources = resources
}
enum CodingKeys: String, CodingKey { case item, modifiedAt, hidden, resources }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(item, forKey: .item)
try container.encode(modifiedAt, forKey: .modifiedAt)
try container.encode(hidden, forKey: .hidden)
try container.encode(resources, forKey: .resources)
}
}
public struct ChangesOutput: Encodable {
/// Token to store for the next call; null only when a full relisting is required.
public let token: String?
public let changed: [ClientMediaItem]
public let deleted: [String]
public let fullResyncRequired: Bool
public init(token: String?, changed: [ClientMediaItem], deleted: [String], fullResyncRequired: Bool) {
self.token = token
self.changed = changed
self.deleted = deleted
self.fullResyncRequired = fullResyncRequired
}
public static func fullResync() -> ChangesOutput {
ChangesOutput(token: nil, changed: [], deleted: [], fullResyncRequired: true)
}
enum CodingKeys: String, CodingKey { case token, changed, deleted, fullResyncRequired }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(token, forKey: .token)
try container.encode(changed, forKey: .changed)
try container.encode(deleted, forKey: .deleted)
try container.encode(fullResyncRequired, forKey: .fullResyncRequired)
}
}
public struct ExportOutput: Encodable, Equatable {
public let path: String
public let fileName: String
public let sizeBytes: Int64
public let sha256: String
public init(path: String, fileName: String, sizeBytes: Int64, sha256: String) {
self.path = path
self.fileName = fileName
self.sizeBytes = sizeBytes
self.sha256 = sha256
}
}

View File

@ -0,0 +1,56 @@
import CryptoKit
import Foundation
/// stdout carries exactly one JSON document per successful command; stderr carries
/// JSON lines (`{"progress":…}` while exporting, `{"error":…}` on failure).
public func encodeJSON<T: Encodable>(_ value: T) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
encoder.nonConformingFloatEncodingStrategy = .throw
let data = try encoder.encode(value)
guard let text = String(data: data, encoding: .utf8) else {
throw HelperError(.internal, "Output was not UTF-8.")
}
return text
}
private struct ErrorEnvelope: Encodable {
struct Body: Encodable {
let code: String
let message: String
}
let error: Body
}
public func errorLine(_ error: HelperError) -> String {
let envelope = ErrorEnvelope(error: .init(code: error.code.rawValue, message: error.message))
return (try? encodeJSON(envelope))
?? #"{"error":{"code":"internal","message":"Could not encode the error."}}"#
}
private struct ProgressLine: Encodable {
let progress: Double
}
/// Progress clamped to 0…1 and rounded to three decimals so the line stays short.
public func progressLine(_ fraction: Double) -> String {
let value = fraction.isFinite ? min(1, max(0, fraction)) : 0
return (try? encodeJSON(ProgressLine(progress: (value * 1000).rounded() / 1000)))
?? #"{"progress":0}"#
}
/// Streams the file through SHA-256 in 1 MiB reads so large videos never load fully.
public func sha256Hex(of url: URL) throws -> (hex: String, sizeBytes: Int64) {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
var size: Int64 = 0
while true {
let chunk = try handle.read(upToCount: 1 << 20) ?? Data()
if chunk.isEmpty { break }
hasher.update(data: chunk)
size += Int64(chunk.count)
}
let hex = hasher.finalize().map { String(format: "%02x", $0) }.joined()
return (hex, size)
}

View File

@ -0,0 +1,131 @@
import Foundation
/// Listing order: undated assets first, then creation date ascending, ties broken by
/// local identifier. PhotoKit can only sort by date, so ties are ordered here.
public struct SortKey: Comparable, Hashable {
/// Milliseconds since 1970; nil for assets without a creation date.
public let creationMillis: Int64?
public let localIdentifier: String
public init(creationMillis: Int64?, localIdentifier: String) {
self.creationMillis = creationMillis
self.localIdentifier = localIdentifier
}
public init(creationDate: Date?, localIdentifier: String) {
self.init(
creationMillis: creationDate.map { Int64(($0.timeIntervalSince1970 * 1000).rounded(.down)) },
localIdentifier: localIdentifier)
}
public static func < (lhs: SortKey, rhs: SortKey) -> Bool {
if lhs.creationMillis != rhs.creationMillis {
switch (lhs.creationMillis, rhs.creationMillis) {
case (nil, _): return true
case (_, nil): return false
case let (left?, right?): return left < right
}
}
return lhs.localIdentifier < rhs.localIdentifier
}
}
/// Opaque to the app: base64url of `{"v":1,"d":<millis|null>,"id":"<localIdentifier>"}`.
/// The cursor names the last item returned, so listing resumes after it even when
/// assets were added or removed in between.
public struct ListCursor: Equatable {
public let key: SortKey
public init(key: SortKey) { self.key = key }
private struct Payload: Codable {
let v: Int
let d: Int64?
let id: String
enum CodingKeys: String, CodingKey { case v, d, id }
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(v, forKey: .v)
try container.encode(d, forKey: .d)
try container.encode(id, forKey: .id)
}
}
public func encoded() -> String {
let payload = Payload(v: 1, d: key.creationMillis, id: key.localIdentifier)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
// Encoding a struct of plain values cannot fail.
let data = (try? encoder.encode(payload)) ?? Data()
return base64URLEncode(data)
}
public static func decode(_ value: String) throws -> ListCursor {
guard value.count <= 2048, let data = base64URLDecode(value),
let payload = try? JSONDecoder().decode(Payload.self, from: data),
payload.v == 1, !payload.id.isEmpty
else { throw HelperError(.usage, "Invalid cursor.") }
return ListCursor(key: SortKey(creationMillis: payload.d, localIdentifier: payload.id))
}
}
public func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
public func base64URLDecode(_ value: String) -> Data? {
guard value.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") })
else { return nil }
var base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while base64.count % 4 != 0 { base64.append("=") }
return Data(base64Encoded: base64)
}
public struct Page {
/// Positions in the source sequence, in listing order.
public let indices: [Int]
public let hasMore: Bool
}
/// Selects the next page from a sequence sorted by creation date only (ties in any
/// order). Binary search finds the first candidate date; a whole same-date group is
/// collected before sorting so tie order never depends on PhotoKit.
public func nextPage(count: Int, key: (Int) -> SortKey, after cursor: ListCursor?, limit: Int)
-> Page
{
precondition(limit > 0)
var start = 0
if let cursor, let cursorMillis = cursor.key.creationMillis {
var low = 0
var high = count
while low < high {
let mid = (low + high) / 2
let millis = key(mid).creationMillis
if millis == nil || millis! < cursorMillis { low = mid + 1 } else { high = mid }
}
start = low
}
var candidates: [(index: Int, key: SortKey)] = []
var stoppedEarly = false
var index = start
while index < count {
let current = key(index)
if candidates.count >= limit, let last = candidates.last, current.creationMillis != last.key.creationMillis {
// Dates ascend, so everything from here on sorts after the collected group.
stoppedEarly = true
break
}
if cursor == nil || cursor!.key < current {
candidates.append((index, current))
}
index += 1
}
candidates.sort { $0.key < $1.key }
let hasMore = stoppedEarly || candidates.count > limit
return Page(indices: candidates.prefix(limit).map(\.index), hasMore: hasMore)
}

View File

@ -0,0 +1,72 @@
import Foundation
/// Where an export is written: a final path and a sibling temporary path inside the
/// destination directory.
public struct ExportDestination: Equatable {
public let directory: URL
public let finalURL: URL
public let temporaryURL: URL
public let fileName: String
}
/// Resolves the export target. The directory must be absolute, free of `..`, existing
/// and a directory after resolving symlinks (macOS temp dirs live behind /var). The file
/// name comes from Photos, so it is reduced to one safe component and the joined path is
/// checked to stay a direct child of the resolved directory. Existing files are never
/// overwritten.
public func resolveExportDestination(
directory: String,
fileName rawFileName: String,
temporarySuffix: String = UUID().uuidString,
fileManager: FileManager = .default
) throws -> ExportDestination {
guard directory.hasPrefix("/"), !directory.contains("\0") else {
throw HelperError(.pathRejected, "--dest must be an absolute directory path.")
}
let components = directory.split(separator: "/", omittingEmptySubsequences: true)
guard !components.contains(where: { $0 == ".." || $0 == "." }) else {
throw HelperError(.pathRejected, "--dest must not contain . or .. segments.")
}
let resolved = URL(fileURLWithPath: directory, isDirectory: true).resolvingSymlinksInPath()
.standardizedFileURL
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: resolved.path, isDirectory: &isDirectory), isDirectory.boolValue
else { throw HelperError(.pathRejected, "--dest must be an existing directory.") }
let fileName = sanitizeFileName(rawFileName)
let finalURL = resolved.appendingPathComponent(fileName, isDirectory: false).standardizedFileURL
let temporaryURL = resolved.appendingPathComponent(
".\(fileName).\(temporarySuffix).partial", isDirectory: false
).standardizedFileURL
for candidate in [finalURL, temporaryURL]
where candidate.deletingLastPathComponent().path != resolved.path {
throw HelperError(.pathRejected, "Export path left the destination directory.")
}
// lstat semantics: a dangling symlink at the final path also counts as existing.
if (try? fileManager.attributesOfItem(atPath: finalURL.path)) != nil {
throw HelperError(.destinationExists, "The destination file already exists.")
}
return ExportDestination(
directory: resolved, finalURL: finalURL, temporaryURL: temporaryURL, fileName: fileName)
}
/// One path component with no separators, control characters or leading dots, at most
/// 200 UTF-8 bytes with the extension kept.
public func sanitizeFileName(_ raw: String) -> String {
var cleaned = String(
raw.unicodeScalars.map { scalar -> Character in
if scalar == "/" || scalar == "\\" || scalar == ":" { return "_" }
if CharacterSet.controlCharacters.contains(scalar) { return "_" }
return Character(scalar)
})
while cleaned.hasPrefix(".") || cleaned.hasPrefix(" ") { cleaned.removeFirst() }
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
if cleaned.isEmpty { return "asset" }
let maxBytes = 200
if cleaned.utf8.count <= maxBytes { return cleaned }
let ext = (cleaned as NSString).pathExtension
let suffix = ext.isEmpty || ext.utf8.count > 16 ? "" : ".\(ext)"
var stem = suffix.isEmpty ? cleaned : String(cleaned.dropLast(suffix.count))
while stem.utf8.count + suffix.utf8.count > maxBytes { stem.removeLast() }
return stem + suffix
}

View File

@ -0,0 +1,148 @@
import AVFoundation
import AppKit
import Foundation
import Photos
import PhotosHelperCore
/// Writes one asset into the destination directory: first to a hidden temporary file,
/// then renamed into place after hashing, so a cancelled export never leaves a file
/// that looks complete.
struct Exporter {
func export(
localIdentifier: String,
directory: String,
variant: ExportVariant,
progress: @escaping (Double) -> Void
) async throws -> ExportOutput {
guard
let asset = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
.firstObject
else { throw HelperError(.notFound, "No photo or video with that identifier.") }
guard let resource = originalResource(for: asset, variant: variant) else {
throw HelperError(.notFound, "The asset has no exportable resource.")
}
var fileName = resource.originalFilename
if variant == .proxy {
let stem = (fileName as NSString).deletingPathExtension
fileName = stem + (asset.mediaType == .video ? ".mp4" : ".jpg")
}
let destination = try resolveExportDestination(directory: directory, fileName: fileName)
let fileManager = FileManager.default
do {
switch (variant, asset.mediaType) {
case (.original, _):
try await writeResource(resource, to: destination.temporaryURL, progress: progress)
case (.proxy, .video):
try await writeVideoProxy(asset, to: destination.temporaryURL, progress: progress)
case (.proxy, _):
try await writeImageProxy(asset, to: destination.temporaryURL, progress: progress)
}
let digest = try sha256Hex(of: destination.temporaryURL)
// moveItem refuses an existing destination, so nothing is ever overwritten.
try fileManager.moveItem(at: destination.temporaryURL, to: destination.finalURL)
progress(1)
return ExportOutput(
path: destination.finalURL.path, fileName: destination.fileName,
sizeBytes: digest.sizeBytes, sha256: digest.hex)
} catch {
try? fileManager.removeItem(at: destination.temporaryURL)
if let helperError = error as? HelperError { throw helperError }
throw mapExportError(error)
}
}
/// Original bytes through PHAssetResourceManager; downloads from iCloud when the
/// library keeps only optimized copies on this Mac.
private func writeResource(
_ resource: PHAssetResource, to url: URL, progress: @escaping (Double) -> Void
) async throws {
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = true
options.progressHandler = { value in progress(value) }
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
PHAssetResourceManager.default().writeData(for: resource, toFile: url, options: options) {
error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
}
}
}
/// 720p H.264 MP4 of the current version, without source metadata.
private func writeVideoProxy(
_ asset: PHAsset, to url: URL, progress: @escaping (Double) -> Void
) async throws {
let options = PHVideoRequestOptions()
options.isNetworkAccessAllowed = true
options.deliveryMode = .highQualityFormat
options.version = .current
options.progressHandler = { value, _, _, _ in progress(value * 0.5) }
let session: AVAssetExportSession = try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestExportSession(
forVideo: asset, options: options, exportPreset: AVAssetExportPreset1280x720
) { session, info in
if let session {
continuation.resume(returning: session)
} else {
continuation.resume(
throwing: (info?[PHImageErrorKey] as? Error)
?? HelperError(.exportFailed, "Could not prepare the video export."))
}
}
}
session.outputURL = url
session.outputFileType = .mp4
session.shouldOptimizeForNetworkUse = true
session.metadata = []
session.metadataItemFilter = .forSharing()
await session.export()
guard session.status == .completed else {
throw session.error ?? HelperError(.exportFailed, "The video export did not finish.")
}
}
/// JPEG of the current version, at most 1280 px on the long edge, without EXIF/GPS.
private func writeImageProxy(
_ asset: PHAsset, to url: URL, progress: @escaping (Double) -> Void
) async throws {
let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
options.deliveryMode = .highQualityFormat
options.resizeMode = .exact
options.version = .current
options.isSynchronous = false
options.progressHandler = { value, _, _, _ in progress(value) }
// highQualityFormat calls the handler exactly once.
let image: NSImage = try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestImage(
for: asset, targetSize: CGSize(width: 1280, height: 1280), contentMode: .aspectFit,
options: options
) { image, info in
if let image {
continuation.resume(returning: image)
} else {
continuation.resume(
throwing: (info?[PHImageErrorKey] as? Error)
?? HelperError(.exportFailed, "Could not render the photo."))
}
}
}
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil),
let data = NSBitmapImageRep(cgImage: cgImage).representation(
using: .jpeg, properties: [.compressionFactor: 0.85])
else { throw HelperError(.exportFailed, "Could not encode the photo.") }
try data.write(to: url, options: .withoutOverwriting)
}
}
/// iCloud and network failures are retryable; everything else is an export failure.
func mapExportError(_ error: Error) -> HelperError {
if let photosError = error as? PHPhotosError,
photosError.code == .networkAccessRequired || photosError.code == .networkError
{
return HelperError(.network, "The original could not be downloaded from iCloud.")
}
if (error as NSError).domain == NSURLErrorDomain {
return HelperError(.network, "The original could not be downloaded from iCloud.")
}
return HelperError(.exportFailed, "The export failed.")
}

View File

@ -0,0 +1,183 @@
import Foundation
import Photos
import PhotosHelperCore
/// Read-only PhotoKit queries. Nothing here writes to the library.
struct Library {
private let library = PHPhotoLibrary.shared()
/// Images and videos only; audio and unknown media types are not part of Media Studio.
/// PhotoKit sorts by date only; the store places missing dates first, which is what
/// `SortKey` assumes, and `nextPage` orders ties itself.
private func fetchOptions(since: Date?) -> PHFetchOptions {
let options = PHFetchOptions()
options.includeHiddenAssets = false
var predicates = [
NSPredicate(
format: "mediaType == %ld || mediaType == %ld",
PHAssetMediaType.image.rawValue, PHAssetMediaType.video.rawValue)
]
if let since {
predicates.append(NSPredicate(format: "modificationDate >= %@", since as NSDate))
}
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
return options
}
func list(after cursor: ListCursor?, limit: Int, since: Date?) throws -> ListOutput {
// Captured before enumeration so changes made while listing show up next time.
let changeToken = try encodeToken(library.currentChangeToken)
let result = PHAsset.fetchAssets(with: fetchOptions(since: since))
let page = nextPage(
count: result.count,
key: { index in
let asset = result.object(at: index)
return SortKey(creationDate: asset.creationDate, localIdentifier: asset.localIdentifier)
},
after: cursor,
limit: limit)
let assets = page.indices.map { result.object(at: $0) }
let items = assets.compactMap(makeItem(for:))
let nextCursor = page.hasMore
? assets.last.map {
ListCursor(key: SortKey(creationDate: $0.creationDate, localIdentifier: $0.localIdentifier))
.encoded()
}
: nil
return ListOutput(items: items, nextCursor: nextCursor, changeToken: changeToken)
}
func asset(_ localIdentifier: String) throws -> AssetOutput {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil).firstObject,
let item = makeItem(for: asset)
else { throw HelperError(.notFound, "No photo or video with that identifier.") }
let resources = PHAssetResource.assetResources(for: asset).map {
AssetResourceInfo(
type: resourceTypeName($0.type), fileName: $0.originalFilename, uti: $0.uniformTypeIdentifier)
}
return AssetOutput(
item: item, modifiedAt: asset.modificationDate, hidden: asset.isHidden, resources: resources)
}
/// Persistent change history (macOS 13). An expired token or missing history asks the
/// caller for a full relisting instead of guessing.
func changes(since encodedToken: String) throws -> ChangesOutput {
guard let token = decodeToken(encodedToken) else { return .fullResync() }
let current = try encodeToken(library.currentChangeToken)
var changed = Set<String>()
var deleted = Set<String>()
do {
let result = try library.fetchPersistentChanges(since: token)
for change in result {
let details = try change.changeDetails(for: PHObjectType.asset)
changed.formUnion(details.insertedLocalIdentifiers)
changed.formUnion(details.updatedLocalIdentifiers)
deleted.formUnion(details.deletedLocalIdentifiers)
if changed.count + deleted.count > Limits.maxChanges { break }
}
} catch let error as PHPhotosError
where error.code == .persistentChangeTokenExpired
|| error.code == .persistentChangeDetailsUnavailable
{
return .fullResync()
} catch {
throw HelperError(.internal, "Could not read the Photos change history.")
}
if changed.count + deleted.count > Limits.maxChanges { return .fullResync() }
changed.subtract(deleted)
var items: [ClientMediaItem] = []
var found = Set<String>()
if !changed.isEmpty {
PHAsset.fetchAssets(withLocalIdentifiers: Array(changed), options: nil).enumerateObjects {
asset, _, _ in
found.insert(asset.localIdentifier)
if let item = makeItem(for: asset) { items.append(item) }
}
}
// Assets that vanished between the history read and the fetch count as deleted.
deleted.formUnion(changed.subtracting(found))
items.sort { $0.externalId < $1.externalId }
return ChangesOutput(
token: current, changed: items, deleted: deleted.sorted(), fullResyncRequired: false)
}
private func encodeToken(_ token: PHPersistentChangeToken) throws -> String {
guard
let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
else { throw HelperError(.internal, "Could not encode the Photos change token.") }
return base64URLEncode(data)
}
private func decodeToken(_ value: String) -> PHPersistentChangeToken? {
guard let data = base64URLDecode(value) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
}
}
/// Builds the contract item. Returns nil for media types Media Studio does not handle
/// and for identifiers longer than the contract allows.
func makeItem(for asset: PHAsset) -> ClientMediaItem? {
let kind: ClientMediaItem.Kind
switch asset.mediaType {
case .video: kind = .video
case .image: kind = asset.mediaSubtypes.contains(.photoLive) ? .livePhoto : .photo
default: return nil
}
guard asset.localIdentifier.utf16.count <= Limits.externalId else { return nil }
var albums: [String] = []
PHAssetCollection.fetchAssetCollectionsContaining(asset, with: .album, options: nil)
.enumerateObjects { collection, _, stop in
if let title = collection.localizedTitle { albums.append(title) }
if albums.count >= Limits.albumCount { stop.pointee = true }
}
let coordinate = asset.location?.coordinate
return ClientMediaItem(
externalId: asset.localIdentifier,
kind: kind,
capturedAt: asset.creationDate,
durationSeconds: kind == .video ? asset.duration : nil,
width: asset.pixelWidth,
height: asset.pixelHeight,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
albumNames: albums,
isFavorite: asset.isFavorite,
fileName: originalResource(for: asset, variant: .original)?.originalFilename)
}
/// The resource holding the untouched original: the video track for videos, the still
/// for photos and Live Photos (the paired motion clip is not exported).
func originalResource(for asset: PHAsset, variant: ExportVariant) -> PHAssetResource? {
let resources = PHAssetResource.assetResources(for: asset)
let preferred: [PHAssetResourceType]
switch (asset.mediaType, variant) {
case (.video, .original): preferred = [.video, .fullSizeVideo]
case (.video, .proxy): preferred = [.fullSizeVideo, .video]
case (_, .original): preferred = [.photo, .fullSizePhoto]
case (_, .proxy): preferred = [.fullSizePhoto, .photo]
}
for type in preferred {
if let match = resources.first(where: { $0.type == type }) { return match }
}
return nil
}
func resourceTypeName(_ type: PHAssetResourceType) -> String {
switch type {
case .photo: return "photo"
case .video: return "video"
case .audio: return "audio"
case .alternatePhoto: return "alternate_photo"
case .fullSizePhoto: return "full_size_photo"
case .fullSizeVideo: return "full_size_video"
case .adjustmentData: return "adjustment_data"
case .adjustmentBasePhoto: return "adjustment_base_photo"
case .pairedVideo: return "paired_video"
case .fullSizePairedVideo: return "full_size_paired_video"
case .adjustmentBasePairedVideo: return "adjustment_base_paired_video"
case .adjustmentBaseVideo: return "adjustment_base_video"
@unknown default: return "other"
}
}

View File

@ -0,0 +1,103 @@
import Foundation
import Photos
import PhotosHelperCore
/// Entry point. Exactly one JSON document goes to stdout on success; failures write
/// `{"error":{code,message}}` to stderr and exit non-zero (see HelperError.exitCode).
@main
struct PhotosHelper {
static func main() async {
do {
let command = try parseArguments(Array(CommandLine.arguments.dropFirst()))
let output = try await run(command)
FileHandle.standardOutput.write(Data((output + "\n").utf8))
exit(0)
} catch let error as HelperError {
fail(error)
} catch {
fail(HelperError(.internal, "Unexpected failure."))
}
}
static func fail(_ error: HelperError) -> Never {
FileHandle.standardError.write(Data((errorLine(error) + "\n").utf8))
exit(error.exitCode)
}
static func run(_ command: Command) async throws -> String {
switch command {
case .authorize(let prompt):
let status = await authorize(prompt: prompt)
return try encodeJSON(AuthorizationOutput(status: status))
case .list(let cursor, let limit, let since):
try requireAccess()
return try encodeJSON(try Library().list(after: cursor, limit: limit, since: since))
case .asset(let localIdentifier):
try requireAccess()
return try encodeJSON(try Library().asset(localIdentifier))
case .export(let localIdentifier, let destination, let variant):
try requireAccess()
let progress = ProgressReporter()
// Local copies report no progress, so a heartbeat repeats the last value; the app
// treats a silent helper as stalled.
let heartbeat = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 15_000_000_000)
if !Task.isCancelled { progress.emit(nil) }
}
}
defer { heartbeat.cancel() }
progress.emit(0)
let result = try await Exporter().export(
localIdentifier: localIdentifier, directory: destination, variant: variant,
progress: { fraction in progress.emit(fraction) })
return try encodeJSON(result)
case .changes(let token):
try requireAccess()
return try encodeJSON(try Library().changes(since: token))
}
}
/// `.readWrite` is the lowest PhotoKit level that can read assets: `.addOnly` cannot
/// enumerate or export anything. The helper never writes to the library.
static func authorize(prompt: Bool) async -> AuthorizationState {
var status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
if prompt && status == .notDetermined {
status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
}
return state(status)
}
static func state(_ status: PHAuthorizationStatus) -> AuthorizationState {
switch status {
case .authorized: return .authorized
case .limited: return .limited
case .denied: return .denied
case .restricted: return .restricted
case .notDetermined: return .notDetermined
@unknown default: return .denied
}
}
/// Serializes progress lines written from PhotoKit callback queues and the heartbeat.
final class ProgressReporter: @unchecked Sendable {
private let lock = NSLock()
private var last = 0.0
/// nil repeats the last reported value.
func emit(_ fraction: Double?) {
lock.lock()
defer { lock.unlock() }
if let fraction { last = max(last, fraction) }
FileHandle.standardError.write(Data((progressLine(last) + "\n").utf8))
}
}
/// Every command except `authorize` runs only with granted access and never prompts.
static func requireAccess() throws {
switch state(PHPhotoLibrary.authorizationStatus(for: .readWrite)) {
case .authorized, .limited: return
default: throw HelperError(.notAuthorized, "Photos access has not been granted.")
}
}
}

View File

@ -0,0 +1,73 @@
import XCTest
@testable import PhotosHelperCore
final class ArgumentsTests: XCTestCase {
func testAuthorizePromptsUnlessAskedNotTo() throws {
XCTAssertEqual(try parseArguments(["authorize"]), .authorize(prompt: true))
XCTAssertEqual(try parseArguments(["authorize", "--no-prompt"]), .authorize(prompt: false))
}
func testListDefaultsAndOptions() throws {
XCTAssertEqual(try parseArguments(["list"]), .list(cursor: nil, limit: 500, since: nil))
let cursor = ListCursor(key: SortKey(creationMillis: 1_700_000_000_000, localIdentifier: "A/L0/001"))
let parsed = try parseArguments([
"list", "--cursor", cursor.encoded(), "--limit", "25", "--since", "2024-01-02T03:04:05Z",
])
XCTAssertEqual(
parsed,
.list(cursor: cursor, limit: 25, since: Date(timeIntervalSince1970: 1_704_164_645)))
}
func testListRejectsBadValues() {
for arguments in [
["list", "--limit", "0"],
["list", "--limit", "1001"],
["list", "--limit", "ten"],
["list", "--limit"],
["list", "--since", "yesterday"],
["list", "--cursor", "not a cursor"],
["list", "--limit", "5", "--limit", "6"],
["list", "--dest", "/tmp"],
["list", "extra"],
["list", "--unknown"],
] {
XCTAssertThrowsError(try parseArguments(arguments), "\(arguments)") { error in
XCTAssertEqual((error as? HelperError)?.code, .usage)
}
}
}
func testAssetNeedsExactlyOneIdentifier() throws {
XCTAssertEqual(try parseArguments(["asset", "ABC/L0/001"]), .asset(localIdentifier: "ABC/L0/001"))
XCTAssertThrowsError(try parseArguments(["asset"]))
XCTAssertThrowsError(try parseArguments(["asset", "a", "b"]))
XCTAssertThrowsError(try parseArguments(["asset", String(repeating: "x", count: 201)]))
XCTAssertThrowsError(try parseArguments(["asset", "bad\nid"]))
}
func testExportVariants() throws {
XCTAssertEqual(
try parseArguments(["export", "ID", "--dest", "/tmp/out"]),
.export(localIdentifier: "ID", destination: "/tmp/out", variant: .original))
XCTAssertEqual(
try parseArguments(["export", "--dest", "/tmp/out", "ID", "--proxy"]),
.export(localIdentifier: "ID", destination: "/tmp/out", variant: .proxy))
XCTAssertThrowsError(try parseArguments(["export", "ID"]))
XCTAssertThrowsError(try parseArguments(["export", "ID", "--dest", "/tmp", "--original", "--proxy"]))
}
func testChangesNeedsToken() throws {
XCTAssertEqual(try parseArguments(["changes", "--token", "abc"]), .changes(token: "abc"))
XCTAssertThrowsError(try parseArguments(["changes"]))
XCTAssertThrowsError(try parseArguments(["changes", "--token", ""]))
}
func testUnknownCommandIsUsageError() {
XCTAssertThrowsError(try parseArguments([])) { error in
XCTAssertEqual((error as? HelperError)?.code, .usage)
XCTAssertEqual((error as? HelperError)?.exitCode, 64)
}
XCTAssertThrowsError(try parseArguments(["delete", "ID"]))
}
}

View File

@ -0,0 +1,92 @@
import XCTest
@testable import PhotosHelperCore
final class OutputTests: XCTestCase {
private func object(_ json: String) throws -> [String: Any] {
try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
}
func testItemEncodesEveryContractKeyWithExplicitNulls() throws {
let item = ClientMediaItem(
externalId: "ID/L0/001", kind: .photo, capturedAt: nil, durationSeconds: nil, width: 0,
height: nil, latitude: nil, longitude: nil, albumNames: [], isFavorite: false, fileName: nil)
let json = try object(encodeJSON(item))
XCTAssertEqual(
Set(json.keys),
[
"externalId", "kind", "capturedAt", "durationMs", "width", "height", "fps", "gpsLat",
"gpsLon", "deviceModel", "albumNames", "isFavorite", "fileName",
])
for key in ["capturedAt", "durationMs", "width", "height", "fps", "gpsLat", "gpsLon", "deviceModel", "fileName"] {
XCTAssertTrue(json[key] is NSNull, key)
}
}
func testItemNormalizesValues() throws {
let item = ClientMediaItem(
externalId: "V/L0/001", kind: .livePhoto, capturedAt: Date(timeIntervalSince1970: 1_704_164_645.5),
durationSeconds: 12.3456, width: 1920, height: 1080, latitude: 52.520_08, longitude: 13.404_95,
albumNames: ["Trip", "", String(repeating: "a", count: 300)] + Array(repeating: "x", count: 60),
isFavorite: true, fileName: "IMG_0001.HEIC")
let json = try object(encodeJSON(item))
XCTAssertEqual(json["kind"] as? String, "live_photo")
XCTAssertEqual(json["capturedAt"] as? String, "2024-01-02T03:04:05.500Z")
XCTAssertEqual(json["durationMs"] as? Int, 12_346)
XCTAssertEqual(json["gpsLat"] as? Double, 52.52)
XCTAssertEqual(json["gpsLon"] as? Double, 13.4)
let albums = try XCTUnwrap(json["albumNames"] as? [String])
XCTAssertEqual(albums.count, 50)
XCTAssertEqual(albums[1].count, 200)
}
func testInvalidCoordinatesAreDropped() {
XCTAssertNil(roundedCoordinate(latitude: 91, longitude: 0.5))
XCTAssertNil(roundedCoordinate(latitude: .nan, longitude: 1))
XCTAssertNil(roundedCoordinate(latitude: 0, longitude: 0))
XCTAssertNil(roundedCoordinate(latitude: 1, longitude: nil))
}
func testClipCountsUTF16Units() {
XCTAssertEqual(clip("abc", maxCharacters: 5), "abc")
// Each emoji is two UTF-16 units; a half character is never kept.
XCTAssertEqual(clip("😀😀😀", maxCharacters: 5), "😀😀")
}
func testErrorAndProgressLinesAreSingleLineJSON() throws {
let line = errorLine(HelperError(.notFound, "No \"asset\"\nhere."))
XCTAssertFalse(line.contains("\n"))
let error = try XCTUnwrap(try object(line)["error"] as? [String: String])
XCTAssertEqual(error["code"], "not_found")
XCTAssertEqual(error["message"], "No \"asset\"\nhere.")
for (input, expected) in [(0.42424, 0.424), (7, 1), (-1, 0), (.nan, 0)] as [(Double, Double)] {
let progress = progressLine(input)
XCTAssertFalse(progress.contains("\n"))
XCTAssertEqual(try object(progress)["progress"] as? Double ?? -1, expected, accuracy: 1e-9)
}
}
func testListAndChangesKeepNullKeys() throws {
let list = try object(encodeJSON(ListOutput(items: [], nextCursor: nil, changeToken: nil)))
XCTAssertTrue(list["nextCursor"] is NSNull)
XCTAssertTrue(list["changeToken"] is NSNull)
let changes = try object(encodeJSON(ChangesOutput.fullResync()))
XCTAssertTrue(changes["token"] is NSNull)
XCTAssertEqual(changes["fullResyncRequired"] as? Bool, true)
}
func testExitCodesAreDistinctPerFailureClass() {
let codes = HelperError.Code.allCases.map { HelperError($0, "").exitCode }
XCTAssertFalse(codes.contains(0))
XCTAssertEqual(HelperError(.notAuthorized, "").exitCode, 77)
}
func testSHA256StreamsFiles() throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: url) }
try Data("abc".utf8).write(to: url)
let digest = try sha256Hex(of: url)
XCTAssertEqual(digest.hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
XCTAssertEqual(digest.sizeBytes, 3)
}
}

View File

@ -0,0 +1,102 @@
import XCTest
@testable import PhotosHelperCore
final class PagingTests: XCTestCase {
func testCursorRoundTripsAndIsURLSafe() throws {
for key in [
SortKey(creationMillis: 1_700_000_000_123, localIdentifier: "9F2A/L0/001"),
SortKey(creationMillis: nil, localIdentifier: "undated+/=?"),
] {
let encoded = ListCursor(key: key).encoded()
XCTAssertFalse(encoded.contains { "+/=".contains($0) }, encoded)
XCTAssertEqual(try ListCursor.decode(encoded).key, key)
}
}
func testCursorRejectsGarbage() {
for value in ["", "!!!", base64URLEncode(Data("{\"v\":2,\"d\":1,\"id\":\"x\"}".utf8)),
base64URLEncode(Data("{\"v\":1,\"d\":1,\"id\":\"\"}".utf8)), base64URLEncode(Data("[]".utf8))]
{
XCTAssertThrowsError(try ListCursor.decode(value), value)
}
}
func testSortKeyPutsUndatedFirstAndBreaksTiesByIdentifier() {
let keys = [
SortKey(creationMillis: 2, localIdentifier: "a"),
SortKey(creationMillis: 1, localIdentifier: "b"),
SortKey(creationMillis: nil, localIdentifier: "z"),
SortKey(creationMillis: 1, localIdentifier: "a"),
SortKey(creationMillis: nil, localIdentifier: "c"),
]
XCTAssertEqual(
keys.sorted().map { key -> String in
let date = key.creationMillis.map { millis in String(millis) } ?? "-"
return date + key.localIdentifier
},
["-c", "-z", "1a", "1b", "2a"])
}
/// Simulates PhotoKit: sorted by date, ties in arbitrary (here reversed) order.
private func photoKitOrder(_ keys: [SortKey]) -> [SortKey] {
keys.sorted { lhs, rhs in
if lhs.creationMillis == rhs.creationMillis { return lhs.localIdentifier > rhs.localIdentifier }
return lhs < rhs
}
}
private func listAll(_ source: [SortKey], limit: Int) -> [SortKey] {
var cursor: ListCursor?
var listed: [SortKey] = []
var pages = 0
while true {
let page = nextPage(count: source.count, key: { source[$0] }, after: cursor, limit: limit)
XCTAssertLessThanOrEqual(page.indices.count, limit)
listed += page.indices.map { source[$0] }
pages += 1
guard page.hasMore, let last = listed.last else { break }
// The cursor survives a round trip through its encoded form, like the app stores it.
cursor = try? ListCursor.decode(ListCursor(key: last).encoded())
if pages > source.count + 1 { XCTFail("paging did not terminate"); break }
}
return listed
}
func testPagesCoverEverythingOnceInStableOrder() {
var keys: [SortKey] = []
for index in 0..<57 {
// Many equal timestamps (bursts) and a few undated assets.
let millis: Int64? = index % 11 == 0 ? nil : Int64(index / 4)
keys.append(SortKey(creationMillis: millis, localIdentifier: String(format: "id-%03d", index)))
}
let source = photoKitOrder(keys)
for limit in [1, 3, 4, 10, 500] {
XCTAssertEqual(listAll(source, limit: limit), keys.sorted(), "limit \(limit)")
}
}
func testEmptyLibraryHasNoMorePages() {
let page = nextPage(count: 0, key: { _ in fatalError() }, after: nil, limit: 10)
XCTAssertEqual(page.indices, [])
XCTAssertFalse(page.hasMore)
}
func testCursorAfterLastItemReturnsNothing() {
let source = [SortKey(creationMillis: 1, localIdentifier: "a")]
let page = nextPage(
count: 1, key: { source[$0] }, after: ListCursor(key: source[0]), limit: 10)
XCTAssertEqual(page.indices, [])
XCTAssertFalse(page.hasMore)
}
func testDeletedCursorItemStillResumesAfterIt() {
let source = [
SortKey(creationMillis: 1, localIdentifier: "a"),
SortKey(creationMillis: 3, localIdentifier: "c"),
]
let gone = ListCursor(key: SortKey(creationMillis: 2, localIdentifier: "b"))
let page = nextPage(count: 2, key: { source[$0] }, after: gone, limit: 10)
XCTAssertEqual(page.indices, [1])
}
}

View File

@ -0,0 +1,83 @@
import XCTest
@testable import PhotosHelperCore
final class PathGuardTests: XCTestCase {
private var directory: URL!
override func setUpWithError() throws {
directory = FileManager.default.temporaryDirectory.appendingPathComponent(
"photos-helper-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: directory)
}
private func resolved() -> URL { directory.resolvingSymlinksInPath().standardizedFileURL }
func testWritesDirectChildrenOfTheResolvedDirectory() throws {
let target = try resolveExportDestination(
directory: directory.path, fileName: "IMG_0001.MOV", temporarySuffix: "t")
XCTAssertEqual(target.finalURL.deletingLastPathComponent().path, resolved().path)
XCTAssertEqual(target.finalURL.lastPathComponent, "IMG_0001.MOV")
XCTAssertEqual(target.temporaryURL.lastPathComponent, ".IMG_0001.MOV.t.partial")
}
func testRejectsRelativeAndTraversingDirectories() {
for path in ["relative/dir", "\(directory.path)/../x", "\(directory.path)/./", "/a/../b", ""] {
XCTAssertThrowsError(
try resolveExportDestination(directory: path, fileName: "a.jpg"), path
) { error in
XCTAssertEqual((error as? HelperError)?.code, .pathRejected)
}
}
}
func testRejectsMissingDirectoryAndFiles() throws {
XCTAssertThrowsError(
try resolveExportDestination(directory: directory.path + "/missing", fileName: "a.jpg"))
let file = directory.appendingPathComponent("file")
try Data().write(to: file)
XCTAssertThrowsError(try resolveExportDestination(directory: file.path, fileName: "a.jpg"))
}
func testHostileFileNamesStayInside() throws {
for name in ["../../etc/passwd", "/etc/passwd", "..", ".hidden", "a/b\\c:d", "\u{0}x", " "] {
let target = try resolveExportDestination(directory: directory.path, fileName: name)
XCTAssertEqual(target.finalURL.deletingLastPathComponent().path, resolved().path, name)
XCTAssertFalse(target.fileName.hasPrefix("."), name)
XCTAssertFalse(target.fileName.contains("/"), name)
}
XCTAssertEqual(sanitizeFileName("../../etc/passwd"), "_.._etc_passwd")
XCTAssertEqual(sanitizeFileName(""), "asset")
}
func testLongNamesKeepTheirExtension() {
let name = sanitizeFileName(String(repeating: "é", count: 300) + ".mov")
XCTAssertLessThanOrEqual(name.utf8.count, 200)
XCTAssertTrue(name.hasSuffix(".mov"))
}
func testNeverOverwritesAnExistingFileOrSymlink() throws {
try Data().write(to: directory.appendingPathComponent("taken.jpg"))
XCTAssertThrowsError(try resolveExportDestination(directory: directory.path, fileName: "taken.jpg")) {
error in XCTAssertEqual((error as? HelperError)?.code, .destinationExists)
}
try FileManager.default.createSymbolicLink(
atPath: directory.appendingPathComponent("link.jpg").path, withDestinationPath: "/nonexistent")
XCTAssertThrowsError(try resolveExportDestination(directory: directory.path, fileName: "link.jpg"))
}
func testSymlinkedDirectoryResolvesToItsTarget() throws {
let link = directory.appendingPathComponent("link", isDirectory: true)
let real = directory.appendingPathComponent("real", isDirectory: true)
try FileManager.default.createDirectory(at: real, withIntermediateDirectories: false)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: real)
let target = try resolveExportDestination(directory: link.path, fileName: "a.jpg")
XCTAssertEqual(
target.finalURL.deletingLastPathComponent().path,
real.resolvingSymlinksInPath().standardizedFileURL.path)
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- electron-builder's default hardened-runtime entitlements for Electron. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Hardened runtime blocks Photos without this, for the app and the bundled photos-helper. -->
<key>com.apple.security.personal-information.photos-library</key>
<true/>
</dict>
</plist>

View File

@ -16,6 +16,8 @@
"release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never"
},
"dependencies": {
"@orpc/client": "1.15.0",
"@orpc/contract": "1.15.0",
"@rakazo/contracts": "workspace:*",
"electron-updater": "6.8.9"
},
@ -61,7 +63,18 @@
"dmg",
"zip"
],
"notarize": true
"notarize": true,
"entitlements": "native/photos-helper/entitlements.mac.plist",
"entitlementsInherit": "native/photos-helper/entitlements.mac.plist",
"extendInfo": {
"NSPhotoLibraryUsageDescription": "Rakazo adds your photos and videos to the media library on your Rakazo server."
},
"extraResources": [
{
"from": "native/photos-helper/.build/apple/Products/Release/photos-helper",
"to": "bin/photos-helper"
}
]
},
"win": {
"icon": "assets/icon.ico",

View File

@ -2,7 +2,10 @@ import { randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type { DesktopReachability, DesktopSetup } from "@rakazo/contracts";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { ContractRouterClient } from "@orpc/contract";
import type { AppContract, DesktopReachability, DesktopSetup } from "@rakazo/contracts";
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts/local-settings";
import {
app,
@ -32,6 +35,14 @@ import {
stackDir,
stackResourceDir,
} from "./local-stack.js";
import type { MediaIpcEvent } from "./media-ipc.js";
import { registerMediaIpc } from "./media-ipc.js";
import { createPhotosHelper, resolvePhotosHelperBinary } from "./media-photos-helper.js";
import {
createChunkUploader,
createPhotosSyncFileStore,
createPhotosSyncRunner,
} from "./media-photos-sync.js";
import { oauthCallbackFrom } from "./oauth-callback.js";
import {
bundledRendererCandidates,
@ -1130,6 +1141,60 @@ app.whenReady().then(async () => {
);
},
);
const photosBinary = resolvePhotosHelperBinary({
platform: process.platform,
packaged: app.isPackaged,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
exists: existsSync,
});
const photosHelper = photosBinary ? createPhotosHelper(photosBinary) : null;
const mediaIpc = registerMediaIpc({
ipcMain,
platform: process.platform,
helper: photosHelper,
isTrusted: (raw: MediaIpcEvent) => {
const event = raw as Electron.IpcMainInvokeEvent;
return fromMainWindow(event) && event.senderFrame === event.sender.mainFrame;
},
runSync: photosHelper
? createPhotosSyncRunner({
helper: photosHelper,
store: createPhotosSyncFileStore(userDataDir),
tempRoot: app.getPath("temp"),
label: "Apple Photos",
connect: async (spaceId) => {
if (currentTargetUrl === null) throw new Error("No server is open.");
const origin = new URL(currentTargetUrl).origin;
const target = (await resolveSessionForTarget(currentTargetUrl)).value;
const fetchWithSession = (input: string | Request, init?: RequestInit) =>
target.fetch(input, {
...init,
credentials: "include",
bypassCustomProtocolHandlers: true,
});
const api: ContractRouterClient<AppContract> = createORPCClient(
new RPCLink({
url: `${origin}/rpc`,
fetch: (request, init) => {
const headers = new Headers(request.headers);
if (spaceId) headers.set("x-rakazo-space-id", spaceId);
return fetchWithSession(new Request(request, { headers }), init);
},
}),
);
return {
origin,
api,
uploadChunk: createChunkUploader({ origin, spaceId, fetch: fetchWithSession }),
};
},
})
: async () => {
throw new Error("Apple Photos is unavailable.");
},
});
app.on("before-quit", mediaIpc.cancel);
ipcMain.handle("desktop.platform", () => process.platform);
const browserPreview = new BrowserPreviewController();
// The in-app browser answers only the app window's own top frame, never a page inside it.

View File

@ -0,0 +1,161 @@
import { describe, expect, it, vi } from "vitest";
import type { MediaIpcEvent, MediaIpcOptions } from "./media-ipc.js";
import {
MEDIA_PHOTOS_CHANNELS,
outcomeForError,
parseSyncRequest,
registerMediaIpc,
} from "./media-ipc.js";
import type { PhotosHelper } from "./media-photos-helper.js";
import { PhotosHelperError } from "./media-photos-helper.js";
import type { PhotosSyncResult } from "./media-photos-sync.js";
import { PhotosSyncError } from "./media-photos-sync.js";
type Listener = (event: MediaIpcEvent, ...args: unknown[]) => unknown;
const result: PhotosSyncResult = {
indexed: 2,
created: 2,
updated: 0,
unchanged: 0,
uploaded: 0,
fullListing: true,
};
function setup(overrides: Partial<MediaIpcOptions> = {}) {
const handlers = new Map<string, Listener>();
const helper = {
authorize: vi.fn(async () => "authorized" as const),
} as unknown as PhotosHelper;
const options: MediaIpcOptions = {
ipcMain: { handle: (channel, listener) => handlers.set(channel, listener) },
platform: "darwin",
helper,
isTrusted: () => true,
runSync: vi.fn(async () => result),
...overrides,
};
const registration = registerMediaIpc(options);
const sent: [string, unknown][] = [];
const event: MediaIpcEvent = {
sender: { send: (channel, payload) => sent.push([channel, payload]), isDestroyed: () => false },
};
const call = (channel: string, ...args: unknown[]) => handlers.get(channel)!(event, ...args);
return { handlers, helper, options, registration, call, sent };
}
describe("media IPC on platforms without Apple Photos", () => {
it.each(["linux", "win32"])(
"reports %s as unavailable and rejects sync and cancel",
async (platform) => {
const { call, options, handlers } = setup({ platform });
expect([...handlers.keys()].sort()).toEqual(
[
MEDIA_PHOTOS_CHANNELS.cancel,
MEDIA_PHOTOS_CHANNELS.status,
MEDIA_PHOTOS_CHANNELS.sync,
].sort(),
);
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: null })).toThrow(/macOS/);
expect(() => call(MEDIA_PHOTOS_CHANNELS.cancel)).toThrow(/macOS/);
expect(options.runSync).not.toHaveBeenCalled();
expect(options.helper!.authorize).not.toHaveBeenCalled();
},
);
it("treats a macOS build without the helper as unavailable", async () => {
const { call } = setup({ helper: null });
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, {})).toThrow(/macOS/);
});
it("treats a helper that cannot start as unavailable", async () => {
const { call, helper } = setup();
vi.mocked(helper.authorize).mockRejectedValueOnce(new PhotosHelperError("unavailable", "x"));
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({ available: false });
});
});
describe("media IPC on macOS", () => {
it("reads status without prompting", async () => {
const { call, helper } = setup();
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toEqual({
available: true,
authorization: "authorized",
syncing: false,
});
expect(helper.authorize).toHaveBeenCalledWith(false);
});
it("runs one sync at a time, forwards progress and resolves with the outcome", async () => {
let finish: (value: PhotosSyncResult) => void = () => {};
const runSync = vi.fn(
({ onProgress }: Parameters<MediaIpcOptions["runSync"]>[0]) =>
new Promise<PhotosSyncResult>((resolve) => {
onProgress({ phase: "indexing", indexed: 1, uploadsDone: 0, uploadsTotal: 0 });
finish = resolve;
}),
);
const { call, sent } = setup({ runSync });
const first = call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: "space_1" });
const second = call(MEDIA_PHOTOS_CHANNELS.sync, { spaceId: "space_1" });
expect(second).toBe(first);
expect(runSync).toHaveBeenCalledTimes(1);
expect(runSync.mock.calls[0]?.[0].spaceId).toBe("space_1");
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toMatchObject({ syncing: true });
expect(sent).toEqual([
[
MEDIA_PHOTOS_CHANNELS.progress,
{ phase: "indexing", indexed: 1, uploadsDone: 0, uploadsTotal: 0 },
],
]);
finish(result);
await expect(first).resolves.toEqual({ ok: true, result });
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).resolves.toMatchObject({ syncing: false });
});
it("cancels the running sync", async () => {
const runSync = vi.fn(
({ signal }: Parameters<MediaIpcOptions["runSync"]>[0]) =>
new Promise<PhotosSyncResult>((_resolve, reject) => {
signal.addEventListener("abort", () =>
reject(new PhotosSyncError("cancelled", "Sync cancelled.")),
);
}),
);
const { call } = setup({ runSync });
const running = call(MEDIA_PHOTOS_CHANNELS.sync, {});
await call(MEDIA_PHOTOS_CHANNELS.cancel);
await expect(running).resolves.toEqual({
ok: false,
code: "cancelled",
message: "Sync cancelled.",
});
});
it("rejects untrusted senders and malformed requests", async () => {
const { call } = setup({ isTrusted: () => false });
expect(() => call(MEDIA_PHOTOS_CHANNELS.sync, {})).toThrow("Not allowed.");
await expect(call(MEDIA_PHOTOS_CHANNELS.status)).rejects.toThrow("Not allowed.");
expect(() => parseSyncRequest(null)).toThrow();
expect(() => parseSyncRequest({ spaceId: "../x" })).toThrow();
expect(() => parseSyncRequest({ spaceId: 4 })).toThrow();
expect(parseSyncRequest({})).toEqual({ spaceId: null });
});
it("never forwards unexpected error text to the renderer", () => {
expect(outcomeForError(new Error("/Users/example/secret path"))).toEqual({
ok: false,
code: "failed",
message: "Apple Photos sync failed.",
});
expect(
outcomeForError(
new PhotosHelperError("not_authorized", "Photos access has not been granted."),
),
).toMatchObject({
code: "not_authorized",
});
});
});

View File

@ -0,0 +1,126 @@
import type {
PhotosSourceStatus,
PhotosSyncOutcome,
PhotosSyncProgress,
PhotosSyncResult,
RakazoDesktopMediaPhotos,
} from "@rakazo/contracts";
import type { PhotosHelper } from "./media-photos-helper.js";
import { PhotosHelperError } from "./media-photos-helper.js";
import { PhotosSyncError } from "./media-photos-sync.js";
/*
* IPC for the Apple Photos source. The renderer only expresses intent (sync, cancel)
* and renders state; the main process owns the helper, the session and the upload.
*/
export const MEDIA_PHOTOS_CHANNELS = {
status: "desktop.media.photos.status",
sync: "desktop.media.photos.sync",
cancel: "desktop.media.photos.cancel",
progress: "desktop.media.photos.progress",
} as const;
export type { PhotosSourceStatus, PhotosSyncOutcome, RakazoDesktopMediaPhotos };
export interface MediaIpcEvent {
sender: { send(channel: string, payload: unknown): void; isDestroyed(): boolean };
}
export interface MediaIpcMain {
handle(channel: string, listener: (event: MediaIpcEvent, ...args: unknown[]) => unknown): void;
}
export interface MediaIpcOptions {
ipcMain: MediaIpcMain;
platform: string;
/** Null when the helper binary is missing (unbuilt dev checkout). */
helper: PhotosHelper | null;
/** Same gate as the other app-window handlers: main window, main frame. */
isTrusted: (event: MediaIpcEvent) => boolean;
runSync: (request: {
spaceId: string | null;
signal: AbortSignal;
onProgress: (progress: PhotosSyncProgress) => void;
}) => Promise<PhotosSyncResult>;
}
const UNAVAILABLE = "Apple Photos is only available in the macOS app.";
const SPACE_ID = /^[A-Za-z0-9_-]{1,200}$/;
export function parseSyncRequest(value: unknown): { spaceId: string | null } {
if (typeof value !== "object" || value === null) throw new Error("Invalid sync request.");
const spaceId = (value as { spaceId?: unknown }).spaceId ?? null;
if (spaceId !== null && (typeof spaceId !== "string" || !SPACE_ID.test(spaceId))) {
throw new Error("Invalid sync request.");
}
return { spaceId };
}
/** Registers the handlers; `cancel()` stops a running sync, e.g. before quit. */
export function registerMediaIpc(options: MediaIpcOptions): { cancel: () => void } {
const available = options.platform === "darwin" && options.helper !== null;
let running: { controller: AbortController; done: Promise<PhotosSyncOutcome> } | null = null;
const requireTrusted = (event: MediaIpcEvent) => {
if (!options.isTrusted(event)) throw new Error("Not allowed.");
};
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.status, async (event) => {
requireTrusted(event);
if (!available) return { available: false } satisfies PhotosSourceStatus;
try {
const authorization = await options.helper!.authorize(false);
return { available: true, authorization, syncing: running !== null };
} catch (error) {
if (error instanceof PhotosHelperError && error.code === "unavailable") {
return { available: false } satisfies PhotosSourceStatus;
}
throw error;
}
});
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.sync, (event, request: unknown) => {
requireTrusted(event);
if (!available) throw new Error(UNAVAILABLE);
const { spaceId } = parseSyncRequest(request);
// A second click joins the running sync instead of starting a parallel one.
if (running) return running.done;
const controller = new AbortController();
const done = options
.runSync({
spaceId,
signal: controller.signal,
onProgress: (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send(MEDIA_PHOTOS_CHANNELS.progress, progress);
}
},
})
.then(
(result): PhotosSyncOutcome => ({ ok: true, result }),
(error: unknown): PhotosSyncOutcome => outcomeForError(error),
)
.finally(() => {
if (running?.controller === controller) running = null;
});
running = { controller, done };
return done;
});
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.cancel, (event) => {
requireTrusted(event);
if (!available) throw new Error(UNAVAILABLE);
running?.controller.abort();
});
return { cancel: () => running?.controller.abort() };
}
/** Only our own fixed messages reach the renderer; anything else is generic. */
export function outcomeForError(error: unknown): PhotosSyncOutcome {
if (error instanceof PhotosSyncError || error instanceof PhotosHelperError) {
return { ok: false, code: error.code, message: error.message };
}
return { ok: false, code: "failed", message: "Apple Photos sync failed." };
}

View File

@ -0,0 +1,437 @@
import { EventEmitter } from "node:events";
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HelperProcess, SpawnHelper } from "./media-photos-helper.js";
import {
changesArgs,
createPhotosHelper,
exportArgs,
helperEnv,
listArgs,
PhotosHelperError,
parseAssetOutput,
parseAuthorizeOutput,
parseChangesOutput,
parseExportOutput,
parseHelperError,
parseListOutput,
parseProgressLine,
photosHelperCandidates,
resolvePhotosHelperBinary,
runPhotosHelper,
} from "./media-photos-helper.js";
const item = (overrides: Record<string, unknown> = {}) => ({
externalId: "9F2A61B0-0000-4000-8000-000000000001/L0/001",
kind: "video",
capturedAt: "2024-01-02T03:04:05.500Z",
durationMs: 12_346,
width: 1920,
height: 1080,
fps: null,
gpsLat: 52.52,
gpsLon: 13.4,
deviceModel: null,
albumNames: ["Trip"],
isFavorite: true,
fileName: "IMG_0001.MOV",
...overrides,
});
const listJson = (items: unknown[], nextCursor: unknown = null, changeToken: unknown = "tok") =>
JSON.stringify({ items, nextCursor, changeToken });
function expectCode(run: () => unknown, code: string) {
try {
run();
} catch (error) {
expect(error).toBeInstanceOf(PhotosHelperError);
expect((error as PhotosHelperError).code).toBe(code);
return;
}
throw new Error("expected a PhotosHelperError");
}
describe("photos helper output parsing", () => {
it("accepts a list page that matches the shared item contract", () => {
const page = parseListOutput(
listJson([item(), item({ kind: "photo", durationMs: null })], "c1"),
2,
);
expect(page.nextCursor).toBe("c1");
expect(page.changeToken).toBe("tok");
expect(page.items).toHaveLength(2);
expect(page.items[0]).toEqual(item());
});
it("accepts the last page of an empty library", () => {
expect(parseListOutput(listJson([], null, null), 500)).toEqual({
items: [],
nextCursor: null,
changeToken: null,
});
});
it.each([
["not JSON", "items: []"],
["an array document", "[]"],
["truncated JSON", '{"items":[{"externalId":'],
["missing items", JSON.stringify({ nextCursor: null, changeToken: null })],
["an item missing a key", listJson([{ ...item(), width: undefined }])],
["an unknown kind", listJson([item({ kind: "audio" })])],
["out-of-range GPS", listJson([item({ gpsLat: 91 })])],
["a non-UTC-parsable date", listJson([item({ capturedAt: "yesterday" })])],
["a negative duration", listJson([item({ durationMs: -1 })])],
["an empty external id", listJson([item({ externalId: "" })])],
["an empty cursor", listJson([item()], "")],
["a numeric cursor", listJson([item()], 3)],
["too many items", listJson([item(), item(), item()])],
])("rejects %s", (_name, text) => {
expectCode(() => parseListOutput(text, 2), "invalid_output");
});
it("parses authorization states and rejects unknown ones", () => {
expect(parseAuthorizeOutput('{"status":"limited"}')).toBe("limited");
expect(parseAuthorizeOutput('{"status":"notDetermined"}')).toBe("notDetermined");
expectCode(() => parseAuthorizeOutput('{"status":"maybe"}'), "invalid_output");
expectCode(() => parseAuthorizeOutput(""), "invalid_output");
});
it("parses asset details", () => {
const detail = parseAssetOutput(
JSON.stringify({
item: item(),
modifiedAt: "2024-02-01T00:00:00.000Z",
hidden: false,
resources: [{ type: "video", fileName: "IMG_0001.MOV", uti: "com.apple.quicktime-movie" }],
}),
);
expect(detail.resources[0]?.type).toBe("video");
expectCode(
() => parseAssetOutput(JSON.stringify({ item: item(), modifiedAt: null, resources: [] })),
"invalid_output",
);
});
it("parses changes and requires a token unless a full relisting is needed", () => {
expect(
parseChangesOutput(
JSON.stringify({
token: "t2",
changed: [item()],
deleted: ["x/L0/001"],
fullResyncRequired: false,
}),
),
).toMatchObject({ token: "t2", deleted: ["x/L0/001"], fullResyncRequired: false });
expect(
parseChangesOutput(
JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: true }),
).fullResyncRequired,
).toBe(true);
expectCode(
() =>
parseChangesOutput(
JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: false }),
),
"invalid_output",
);
expectCode(
() =>
parseChangesOutput(
JSON.stringify({ token: "t", changed: [], deleted: [7], fullResyncRequired: false }),
),
"invalid_output",
);
});
it("maps helper error lines to codes", () => {
const stderr =
'{"progress":0.5}\n{"error":{"code":"not_authorized","message":"Photos access has not been granted."}}\n';
const error = parseHelperError(stderr, 77);
expect(error.code).toBe("not_authorized");
expect(error.message).toBe("Photos access has not been granted.");
expect(parseHelperError('{"error":{"code":"internal","message":"x"}}', 70).code).toBe("failed");
expect(parseHelperError("Segmentation fault", 139).message).toContain("139");
expect(parseHelperError('{"error":"oops"}', 1).code).toBe("failed");
});
it("reads progress lines only", () => {
expect(parseProgressLine('{"progress":0.42}')).toBe(0.42);
expect(parseProgressLine('{"progress":2}')).toBeNull();
expect(parseProgressLine('{"error":{"code":"usage"}}')).toBeNull();
expect(parseProgressLine('{"progress":')).toBeNull();
});
});
describe("photos helper export result", () => {
let dir: string;
let other: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-export-"));
other = await mkdtemp(path.join(tmpdir(), "rakazo-photos-other-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
await rm(other, { recursive: true, force: true });
});
const result = (file: string, fileName = path.basename(file)) =>
JSON.stringify({ path: file, fileName, sizeBytes: 3, sha256: "a".repeat(64) });
it("accepts a file directly inside the export folder", async () => {
const file = path.join(dir, "IMG_0001.MOV");
await writeFile(file, "abc");
await expect(parseExportOutput(result(file), dir)).resolves.toMatchObject({
fileName: "IMG_0001.MOV",
sizeBytes: 3,
});
});
it("rejects files outside the export folder, including through symlinks", async () => {
const outside = path.join(other, "secret.txt");
await writeFile(outside, "abc");
await expect(parseExportOutput(result(outside), dir)).rejects.toMatchObject({
code: "path_rejected",
});
const link = path.join(dir, "link.MOV");
await symlink(outside, link);
await expect(parseExportOutput(result(link), dir)).rejects.toMatchObject({
code: "path_rejected",
});
const nested = path.join(dir, "..", path.basename(other), "secret.txt");
await expect(parseExportOutput(result(nested), dir)).rejects.toMatchObject({
code: "path_rejected",
});
});
it("rejects malformed results", async () => {
const file = path.join(dir, "a.jpg");
await writeFile(file, "abc");
for (const text of [
JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: 3, sha256: "XYZ" }),
JSON.stringify({ path: "a.jpg", fileName: "a.jpg", sizeBytes: 3, sha256: "a".repeat(64) }),
JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: -1, sha256: "a".repeat(64) }),
JSON.stringify({
path: path.join(dir, "missing.jpg"),
fileName: "missing.jpg",
sizeBytes: 3,
sha256: "a".repeat(64),
}),
]) {
await expect(parseExportOutput(text, dir)).rejects.toMatchObject({ code: "invalid_output" });
}
await expect(parseExportOutput(result(file, "b.jpg"), dir)).rejects.toMatchObject({
code: "path_rejected",
});
});
});
describe("photos helper argv", () => {
it("builds fixed argv arrays", () => {
expect(listArgs({ cursor: null, limit: 500 })).toEqual(["list", "--limit", "500"]);
expect(listArgs({ cursor: "abc_-", limit: 10, since: "2024-01-01T00:00:00Z" })).toEqual([
"list",
"--limit",
"10",
"--cursor",
"abc_-",
"--since",
"2024-01-01T00:00:00Z",
]);
expect(exportArgs("ID/L0/001", "/tmp/rakazo-photos-1")).toEqual([
"export",
"ID/L0/001",
"--dest",
"/tmp/rakazo-photos-1",
"--original",
]);
expect(changesArgs("tok")).toEqual(["changes", "--token", "tok"]);
});
it("keeps shell syntax as inert data and rejects option-like values", () => {
// No shell runs, so metacharacters stay one literal argument.
expect(exportArgs("$(rm -rf ~);`x`", "/tmp/a b")[1]).toBe("$(rm -rf ~);`x`");
expectCode(() => exportArgs("--dest", "/tmp"), "usage");
expectCode(() => listArgs({ cursor: "--limit", limit: 1 }), "usage");
expectCode(() => changesArgs("a\nb"), "usage");
expectCode(() => listArgs({ cursor: null, limit: 501 }), "usage");
expectCode(() => exportArgs("ID", "relative/dir"), "path_rejected");
expectCode(() => exportArgs("ID", "/tmp/../etc"), "path_rejected");
});
it("passes only the environment PhotoKit needs", () => {
expect(
helperEnv({ HOME: "/Users/example", TMPDIR: "/tmp", OPENAI_API_KEY: "secret", PATH: "/bin" }),
).toEqual({ HOME: "/Users/example", TMPDIR: "/tmp" });
});
});
describe("photos helper binary", () => {
it("is only available on macOS", () => {
const options = {
packaged: true,
resourcesPath: "/App/Resources",
appPath: "/app",
exists: () => true,
};
expect(resolvePhotosHelperBinary({ ...options, platform: "linux" })).toBeNull();
expect(resolvePhotosHelperBinary({ ...options, platform: "win32" })).toBeNull();
expect(resolvePhotosHelperBinary({ ...options, platform: "darwin" })).toBe(
"/App/Resources/bin/photos-helper",
);
});
it("falls back to the development build and reports a missing build", () => {
const options = {
platform: "darwin",
packaged: false,
resourcesPath: "/r",
appPath: "/repo/apps/desktop",
};
expect(photosHelperCandidates(options)).toEqual([
"/repo/apps/desktop/native/photos-helper/.build/apple/Products/Release/photos-helper",
"/repo/apps/desktop/native/photos-helper/.build/release/photos-helper",
]);
expect(
resolvePhotosHelperBinary({
...options,
exists: (file) => file.endsWith(".build/release/photos-helper"),
}),
).toBe("/repo/apps/desktop/native/photos-helper/.build/release/photos-helper");
expect(resolvePhotosHelperBinary({ ...options, exists: () => false })).toBeNull();
});
});
class FakeProcess extends EventEmitter implements HelperProcess {
stdout = new PassThrough();
stderr = new PassThrough();
kill = vi.fn(() => {
queueMicrotask(() => this.emit("close", null));
return true;
});
exit(code: number, stdout = "", stderr = "") {
if (stdout) this.stdout.write(stdout);
if (stderr) this.stderr.write(stderr);
setImmediate(() => this.emit("close", code));
}
}
function fakeSpawn() {
const processes: FakeProcess[] = [];
const spawn = vi.fn<SpawnHelper>(() => {
const child = new FakeProcess();
processes.push(child);
return child;
});
return { spawn, processes };
}
describe("photos helper process", () => {
it("spawns the binary with argv and no shell", async () => {
const { spawn, processes } = fakeSpawn();
const helper = createPhotosHelper("/App/Resources/bin/photos-helper", spawn);
const pending = helper.list({ cursor: null, limit: 2 });
processes[0]!.exit(0, listJson([item()]));
await expect(pending).resolves.toMatchObject({ items: [item()] });
expect(spawn).toHaveBeenCalledWith(
"/App/Resources/bin/photos-helper",
["list", "--limit", "2"],
expect.objectContaining({ shell: false, stdio: ["ignore", "pipe", "pipe"] }),
);
});
it("maps a failing exit to the helper's error code", async () => {
const { spawn, processes } = fakeSpawn();
const pending = createPhotosHelper("/bin/photos-helper", spawn).asset("ID/L0/001");
processes[0]!.exit(
66,
"",
'{"error":{"code":"not_found","message":"No photo or video with that identifier."}}\n',
);
await expect(pending).rejects.toMatchObject({ code: "not_found" });
});
it("reports export progress and validates the result", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-run-"));
try {
const file = path.join(dir, "IMG_0001.MOV");
await writeFile(file, "abc");
const { spawn, processes } = fakeSpawn();
const progress: number[] = [];
const pending = createPhotosHelper("/bin/photos-helper", spawn).exportOriginal("ID", dir, {
onProgress: (value) => progress.push(value),
});
const child = processes[0]!;
child.stderr.write('{"progress":0}\n{"progress":0.5');
child.stderr.write('}\nnoise\n{"progress":1}\n');
child.exit(
0,
JSON.stringify({
path: file,
fileName: "IMG_0001.MOV",
sizeBytes: 3,
sha256: "b".repeat(64),
}),
);
await expect(pending).resolves.toMatchObject({ sizeBytes: 3 });
expect(progress).toEqual([0, 0.5, 1]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("kills the helper on abort and on timeout", async () => {
const { spawn, processes } = fakeSpawn();
const controller = new AbortController();
const aborted = runPhotosHelper("/bin/h", ["list"], {
spawn,
timeoutMs: 60_000,
signal: controller.signal,
});
controller.abort();
await expect(aborted).rejects.toMatchObject({ code: "cancelled" });
expect(processes[0]!.kill).toHaveBeenCalledWith("SIGTERM");
const timedOut = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 5 });
await expect(timedOut).rejects.toMatchObject({ code: "timeout" });
expect(processes[1]!.kill).toHaveBeenCalled();
});
it("fails a silent export through the idle limit", async () => {
const { spawn, processes } = fakeSpawn();
const pending = runPhotosHelper("/bin/h", ["export"], {
spawn,
timeoutMs: 60_000,
idleTimeoutMs: 5,
});
await expect(pending).rejects.toMatchObject({ code: "timeout" });
expect(processes[0]!.kill).toHaveBeenCalled();
});
it("does not start when already aborted and reports spawn failures", async () => {
const { spawn, processes } = fakeSpawn();
const controller = new AbortController();
controller.abort();
await expect(
runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000, signal: controller.signal }),
).rejects.toMatchObject({ code: "cancelled" });
expect(spawn).not.toHaveBeenCalled();
const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 });
processes[0]!.emit("error", new Error("ENOENT"));
await expect(pending).rejects.toMatchObject({ code: "unavailable" });
});
it("rejects oversized output", async () => {
const { spawn, processes } = fakeSpawn();
const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 });
processes[0]!.stdout.write(Buffer.alloc(33 * 1024 * 1024));
await expect(pending).rejects.toMatchObject({ code: "invalid_output" });
});
});

View File

@ -0,0 +1,512 @@
import { spawn as nodeSpawn } from "node:child_process";
import { realpath } from "node:fs/promises";
import path from "node:path";
import type { Readable } from "node:stream";
import type { ClientMediaItem, PhotosAuthorization } from "@rakazo/contracts";
import { ClientMediaItemSchema } from "@rakazo/contracts";
/*
* Client for the macOS `photos-helper` CLI (apps/desktop/native/photos-helper). The
* helper runs with a fixed argv and no shell; every byte it prints is validated before
* the app uses it, so a malformed or unexpected answer fails instead of being guessed at.
*/
export type { PhotosAuthorization };
export interface PhotosListPage {
items: ClientMediaItem[];
nextCursor: string | null;
/** Library change token from before this page was read. */
changeToken: string | null;
}
export interface PhotosAssetDetail {
item: ClientMediaItem;
modifiedAt: string | null;
hidden: boolean;
resources: { type: string; fileName: string; uti: string }[];
}
export interface PhotosChanges {
token: string | null;
changed: ClientMediaItem[];
deleted: string[];
fullResyncRequired: boolean;
}
export interface PhotosExport {
path: string;
fileName: string;
sizeBytes: number;
sha256: string;
}
export type PhotosHelperErrorCode =
| "unavailable"
| "usage"
| "not_authorized"
| "not_found"
| "path_rejected"
| "destination_exists"
| "network"
| "export_failed"
| "invalid_output"
| "timeout"
| "cancelled"
| "failed";
const HELPER_CODES = new Set<PhotosHelperErrorCode>([
"usage",
"not_authorized",
"not_found",
"path_rejected",
"destination_exists",
"network",
"export_failed",
]);
export class PhotosHelperError extends Error {
constructor(
readonly code: PhotosHelperErrorCode,
message: string,
) {
super(message);
this.name = "PhotosHelperError";
}
}
export const PHOTOS_HELPER_NAME = "photos-helper";
/** Matches the contract's batch limit for `media.sources.pushItems`. */
export const PHOTOS_MAX_PAGE = 500;
/**
* Packaged apps ship the universal helper in Resources/bin; development uses the
* SwiftPM output (universal `--arch` build first, then the host-arch build).
*/
export function photosHelperCandidates(options: {
packaged: boolean;
resourcesPath: string;
appPath: string;
}): string[] {
if (options.packaged) return [path.join(options.resourcesPath, "bin", PHOTOS_HELPER_NAME)];
const build = path.join(options.appPath, "native", "photos-helper", ".build");
return [
path.join(build, "apple", "Products", "Release", PHOTOS_HELPER_NAME),
path.join(build, "release", PHOTOS_HELPER_NAME),
];
}
/** Null off macOS or when the helper was not built; the Apple Photos source is then hidden. */
export function resolvePhotosHelperBinary(options: {
platform: string;
packaged: boolean;
resourcesPath: string;
appPath: string;
exists: (file: string) => boolean;
}): string | null {
if (options.platform !== "darwin") return null;
return photosHelperCandidates(options).find((candidate) => options.exists(candidate)) ?? null;
}
// ---------------------------------------------------------------------------
// argv
// ---------------------------------------------------------------------------
export function authorizeArgs(prompt: boolean): string[] {
return prompt ? ["authorize"] : ["authorize", "--no-prompt"];
}
export function listArgs(request: {
cursor: string | null;
limit: number;
since?: string | null;
}): string[] {
if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > PHOTOS_MAX_PAGE) {
throw new PhotosHelperError("usage", "List limit is out of range.");
}
const args = ["list", "--limit", String(request.limit)];
if (request.cursor !== null) args.push("--cursor", opaqueArgument(request.cursor));
if (request.since) args.push("--since", opaqueArgument(request.since));
return args;
}
export function assetArgs(localIdentifier: string): string[] {
return ["asset", identifierArgument(localIdentifier)];
}
export function exportArgs(localIdentifier: string, destination: string): string[] {
if (!path.isAbsolute(destination) || destination.split(/[\\/]/).includes("..")) {
throw new PhotosHelperError("path_rejected", "Export directory must be absolute.");
}
return ["export", identifierArgument(localIdentifier), "--dest", destination, "--original"];
}
export function changesArgs(token: string): string[] {
return ["changes", "--token", opaqueArgument(token)];
}
/** Identifiers come from the helper itself; a leading dash would read as an option. */
function identifierArgument(value: string): string {
if (value === "" || value.length > 200 || value.startsWith("-") || hasControl(value)) {
throw new PhotosHelperError("usage", "Invalid Photos identifier.");
}
return value;
}
function opaqueArgument(value: string): string {
if (value === "" || value.length > 4096 || value.startsWith("-") || hasControl(value)) {
throw new PhotosHelperError("usage", "Invalid helper argument.");
}
return value;
}
function hasControl(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Output parsing
// ---------------------------------------------------------------------------
function invalid(what: string): never {
throw new PhotosHelperError("invalid_output", `Photos helper returned invalid ${what}.`);
}
function parseDocument(text: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
invalid("JSON");
}
if (typeof value !== "object" || value === null || Array.isArray(value)) invalid("JSON");
return value as Record<string, unknown>;
}
function nullableString(value: unknown, what: string, max = 4096): string | null {
if (value === null) return null;
if (typeof value !== "string" || value === "" || value.length > max) invalid(what);
return value;
}
/** Each item must satisfy the shared contract schema exactly as the server will parse it. */
function parseItems(value: unknown, what: string): ClientMediaItem[] {
if (!Array.isArray(value) || value.length > 10_000) invalid(what);
return value.map((entry) => {
if (typeof entry !== "object" || entry === null) invalid(what);
const parsed = ClientMediaItemSchema.safeParse(entry);
if (!parsed.success) invalid(what);
return parsed.data;
});
}
const AUTHORIZATION = new Set<PhotosAuthorization>([
"authorized",
"limited",
"denied",
"restricted",
"notDetermined",
]);
export function parseAuthorizeOutput(text: string): PhotosAuthorization {
const status = parseDocument(text).status;
if (typeof status !== "string" || !AUTHORIZATION.has(status as PhotosAuthorization)) {
invalid("authorization status");
}
return status as PhotosAuthorization;
}
export function parseListOutput(text: string, limit: number): PhotosListPage {
const document = parseDocument(text);
const items = parseItems(document.items, "list items");
if (items.length > limit) invalid("list page size");
const nextCursor = nullableString(document.nextCursor, "list cursor");
const changeToken = nullableString(document.changeToken, "change token");
return { items, nextCursor, changeToken };
}
export function parseAssetOutput(text: string): PhotosAssetDetail {
const document = parseDocument(text);
const [item] = parseItems([document.item], "asset");
const modifiedAt = nullableString(document.modifiedAt, "asset date", 64);
if (modifiedAt !== null && Number.isNaN(Date.parse(modifiedAt))) invalid("asset date");
if (typeof document.hidden !== "boolean") invalid("asset");
if (!Array.isArray(document.resources) || document.resources.length > 50) invalid("resources");
const resources = document.resources.map((resource) => {
if (typeof resource !== "object" || resource === null) invalid("resources");
const { type, fileName, uti } = resource as Record<string, unknown>;
if (typeof type !== "string" || typeof fileName !== "string" || typeof uti !== "string") {
invalid("resources");
}
return { type, fileName, uti };
});
return { item: item!, modifiedAt, hidden: document.hidden, resources };
}
export function parseChangesOutput(text: string): PhotosChanges {
const document = parseDocument(text);
const token = nullableString(document.token, "change token");
if (typeof document.fullResyncRequired !== "boolean") invalid("changes");
const changed = parseItems(document.changed, "changed items");
if (!Array.isArray(document.deleted) || document.deleted.length > 10_000) invalid("changes");
const deleted = document.deleted.map((id) => {
if (typeof id !== "string" || id === "" || id.length > 200) invalid("deleted ids");
return id;
});
if (!document.fullResyncRequired && token === null) invalid("change token");
return { token, changed, deleted, fullResyncRequired: document.fullResyncRequired };
}
/**
* Besides the shape, the file must be a direct child of the directory the app chose,
* after resolving symlinks, so a confused helper cannot point the upload elsewhere.
*/
export async function parseExportOutput(text: string, destination: string): Promise<PhotosExport> {
const document = parseDocument(text);
const { path: file, fileName, sizeBytes, sha256 } = document;
if (
typeof file !== "string" ||
!path.isAbsolute(file) ||
typeof fileName !== "string" ||
fileName === "" ||
typeof sizeBytes !== "number" ||
!Number.isSafeInteger(sizeBytes) ||
sizeBytes < 0 ||
typeof sha256 !== "string" ||
!/^[0-9a-f]{64}$/.test(sha256)
) {
invalid("export result");
}
let real: string;
let realDestination: string;
try {
[real, realDestination] = await Promise.all([realpath(file), realpath(destination)]);
} catch {
invalid("export path");
}
if (path.dirname(real) !== realDestination || path.basename(real) !== fileName) {
throw new PhotosHelperError("path_rejected", "Photos helper wrote outside the export folder.");
}
return { path: real, fileName, sizeBytes, sha256 };
}
/** The last `{"error":{code,message}}` line on stderr; unknown codes become `failed`. */
export function parseHelperError(stderr: string, exitCode: number | null): PhotosHelperError {
const lines = stderr.split("\n").reverse();
for (const line of lines) {
if (!line.startsWith("{")) continue;
try {
const error = (JSON.parse(line) as { error?: unknown }).error as
| { code?: unknown; message?: unknown }
| undefined;
if (typeof error?.code !== "string") continue;
const code = HELPER_CODES.has(error.code as PhotosHelperErrorCode)
? (error.code as PhotosHelperErrorCode)
: "failed";
const message =
typeof error.message === "string" && error.message.length <= 500
? error.message
: "Photos helper failed.";
return new PhotosHelperError(code, message);
} catch {}
}
return new PhotosHelperError("failed", `Photos helper exited with status ${exitCode ?? "?"}.`);
}
/** `{"progress":0.42}` lines on stderr; anything else is ignored. */
export function parseProgressLine(line: string): number | null {
if (!line.startsWith('{"progress"')) return null;
try {
const value = (JSON.parse(line) as { progress?: unknown }).progress;
return typeof value === "number" && value >= 0 && value <= 1 ? value : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Process
// ---------------------------------------------------------------------------
/** The subset of ChildProcess the runner needs, so tests can supply a fake. */
export interface HelperProcess {
stdout: Readable | null;
stderr: Readable | null;
kill(signal?: NodeJS.Signals): boolean;
on(event: "error", listener: (error: Error) => void): unknown;
on(event: "close", listener: (code: number | null) => void): unknown;
}
export type SpawnHelper = (
binary: string,
args: string[],
options: { shell: false; env: Record<string, string>; stdio: ["ignore", "pipe", "pipe"] },
) => HelperProcess;
export interface RunOptions {
signal?: AbortSignal;
/** Hard limit for the whole run. */
timeoutMs: number;
/** Fails when stderr progress stops for this long (exports only). */
idleTimeoutMs?: number;
onProgress?: (fraction: number) => void;
}
const MAX_STDOUT_BYTES = 32 * 1024 * 1024;
const MAX_STDERR_BYTES = 1024 * 1024;
const ENV_ALLOWLIST = ["HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "LC_ALL"];
/** PhotoKit needs the user's home; nothing else from the app environment is passed on. */
export function helperEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const name of ENV_ALLOWLIST) {
const value = env[name];
if (value) result[name] = value;
}
return result;
}
export const defaultSpawnHelper: SpawnHelper = (binary, args, options) =>
nodeSpawn(binary, args, options);
export function runPhotosHelper(
binary: string,
args: string[],
options: RunOptions & { spawn?: SpawnHelper; env?: NodeJS.ProcessEnv },
): Promise<string> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(new PhotosHelperError("cancelled", "Cancelled."));
return;
}
const child = (options.spawn ?? defaultSpawnHelper)(binary, args, {
shell: false,
env: helperEnv(options.env ?? process.env),
stdio: ["ignore", "pipe", "pipe"],
});
const stdout: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let pendingLine = "";
let settled = false;
let idleTimer: NodeJS.Timeout | undefined;
const finish = (error: PhotosHelperError | null, output = "") => {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(idleTimer);
options.signal?.removeEventListener("abort", abort);
if (error) reject(error);
else resolve(output);
};
const stop = (error: PhotosHelperError) => {
child.kill("SIGTERM");
finish(error);
};
const abort = () => stop(new PhotosHelperError("cancelled", "Cancelled."));
const timer = setTimeout(
() => stop(new PhotosHelperError("timeout", "Photos helper timed out.")),
options.timeoutMs,
);
timer.unref?.();
const armIdle = () => {
if (options.idleTimeoutMs === undefined) return;
clearTimeout(idleTimer);
idleTimer = setTimeout(
() => stop(new PhotosHelperError("timeout", "Photos export stalled.")),
options.idleTimeoutMs,
);
idleTimer.unref?.();
};
armIdle();
options.signal?.addEventListener("abort", abort, { once: true });
child.stdout?.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.byteLength;
if (stdoutBytes > MAX_STDOUT_BYTES) {
stop(new PhotosHelperError("invalid_output", "Photos helper output is too large."));
return;
}
stdout.push(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
if (stderr.length < MAX_STDERR_BYTES) stderr += text;
const lines = (pendingLine + text).split("\n");
pendingLine = lines.pop() ?? "";
for (const line of lines) {
const progress = parseProgressLine(line.trim());
if (progress !== null) {
armIdle();
options.onProgress?.(progress);
}
}
});
child.on("error", () => {
finish(new PhotosHelperError("unavailable", "Photos helper could not be started."));
});
child.on("close", (code) => {
if (code === 0) finish(null, Buffer.concat(stdout).toString("utf8"));
else finish(parseHelperError(stderr + pendingLine, code));
});
});
}
const QUERY_TIMEOUT_MS = 2 * 60_000;
/** The system permission dialog waits on the person. */
const AUTHORIZE_TIMEOUT_MS = 10 * 60_000;
/** Large iCloud videos take long; a stalled download fails sooner through the idle limit. */
const EXPORT_TIMEOUT_MS = 2 * 60 * 60_000;
const EXPORT_IDLE_TIMEOUT_MS = 5 * 60_000;
export interface PhotosHelper {
authorize(prompt: boolean, signal?: AbortSignal): Promise<PhotosAuthorization>;
list(
request: { cursor: string | null; limit: number; since?: string | null },
signal?: AbortSignal,
): Promise<PhotosListPage>;
asset(localIdentifier: string, signal?: AbortSignal): Promise<PhotosAssetDetail>;
exportOriginal(
localIdentifier: string,
destination: string,
options?: { signal?: AbortSignal; onProgress?: (fraction: number) => void },
): Promise<PhotosExport>;
changes(token: string, signal?: AbortSignal): Promise<PhotosChanges>;
}
export function createPhotosHelper(binary: string, spawn?: SpawnHelper): PhotosHelper {
const run = (args: string[], options: RunOptions) =>
runPhotosHelper(binary, args, { ...options, spawn });
return {
async authorize(prompt, signal) {
const timeoutMs = prompt ? AUTHORIZE_TIMEOUT_MS : QUERY_TIMEOUT_MS;
return parseAuthorizeOutput(await run(authorizeArgs(prompt), { signal, timeoutMs }));
},
async list(request, signal) {
const output = await run(listArgs(request), { signal, timeoutMs: QUERY_TIMEOUT_MS });
return parseListOutput(output, request.limit);
},
async asset(localIdentifier, signal) {
const output = await run(assetArgs(localIdentifier), { signal, timeoutMs: QUERY_TIMEOUT_MS });
return parseAssetOutput(output);
},
async exportOriginal(localIdentifier, destination, options = {}) {
const output = await run(exportArgs(localIdentifier, destination), {
signal: options.signal,
onProgress: options.onProgress,
timeoutMs: EXPORT_TIMEOUT_MS,
idleTimeoutMs: EXPORT_IDLE_TIMEOUT_MS,
});
return parseExportOutput(output, destination);
},
async changes(token, signal) {
const output = await run(changesArgs(token), { signal, timeoutMs: QUERY_TIMEOUT_MS });
return parseChangesOutput(output);
},
};
}

View File

@ -0,0 +1,655 @@
import { createHash } from "node:crypto";
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ClientMediaItem, MediaAsset, MediaUpload } from "@rakazo/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PhotosHelper } from "./media-photos-helper.js";
import type { MediaSyncApi, PhotosSyncState, PhotosSyncStore } from "./media-photos-sync.js";
import {
createChunkUploader,
createPhotosSyncFileStore,
parsePhotosSyncState,
runPool,
syncApplePhotos,
} from "./media-photos-sync.js";
const sha = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
function libraryItem(index: number): ClientMediaItem {
return {
externalId: `ASSET-${String(index).padStart(5, "0")}/L0/001`,
kind: index % 3 === 0 ? "video" : "photo",
capturedAt: new Date(Date.UTC(2024, 0, 1) + index * 60_000).toISOString(),
durationMs: index % 3 === 0 ? 5000 : null,
width: 1920,
height: 1080,
fps: null,
gpsLat: null,
gpsLon: null,
deviceModel: null,
albumNames: [],
isFavorite: false,
fileName: `IMG_${index}.JPG`,
};
}
/** Pages a synthetic library the way the Swift helper does: opaque cursor = next index. */
function fakeHelper(options: { size: number; contents?: (id: string) => string }) {
const library = Array.from({ length: options.size }, (_, index) => libraryItem(index));
const exports: string[] = [];
let active = 0;
const stats = { maxActiveExports: 0 };
const helper: PhotosHelper & { library: ClientMediaItem[] } = {
library,
authorize: vi.fn(async () => "authorized" as const),
list: vi.fn(async ({ cursor, limit }) => {
const start = cursor === null ? 0 : Number(cursor);
const items = library.slice(start, start + limit);
const next = start + limit < library.length ? String(start + limit) : null;
return { items, nextCursor: next, changeToken: `token-${library.length}` };
}),
asset: vi.fn(),
changes: vi.fn(),
exportOriginal: vi.fn(async (id: string, destination: string, opts) => {
active += 1;
stats.maxActiveExports = Math.max(stats.maxActiveExports, active);
try {
await new Promise((resolve) => setTimeout(resolve, 5));
opts?.signal?.throwIfAborted();
const contents = options.contents?.(id) ?? `original bytes of ${id}`;
const file = path.join(destination, "IMG.MOV");
await writeFile(file, contents);
exports.push(destination);
return {
path: file,
fileName: "IMG.MOV",
sizeBytes: Buffer.byteLength(contents),
sha256: sha(contents),
};
} finally {
active -= 1;
}
}),
};
return { helper, exports, stats };
}
function memoryStore(initial?: PhotosSyncState) {
let state: PhotosSyncState = initial ?? { version: 1, deviceId: "device-1", targets: {} };
const writes: PhotosSyncState[] = [];
const store: PhotosSyncStore = {
read: async () => JSON.parse(JSON.stringify(state)) as PhotosSyncState,
write: async (next) => {
state = JSON.parse(JSON.stringify(next)) as PhotosSyncState;
writes.push(state);
},
};
return { store, writes, current: () => state };
}
function asset(id: string, externalId: string, ingestState: MediaAsset["ingestState"]) {
return {
id,
sourceId: "source-1",
externalId,
kind: "video",
storage: "remote",
capturedAt: null,
durationMs: null,
width: null,
height: null,
fps: null,
gpsLat: null,
gpsLon: null,
deviceModel: null,
albumNames: [],
isFavorite: false,
ingestState,
hasProxy: false,
hasThumbnail: false,
qualityScore: null,
createdAt: "2024-01-01T00:00:00.000Z",
} as MediaAsset;
}
function fakeApi(
options: {
assets?: MediaAsset[];
chunkBytes?: number;
uploads?: Record<string, MediaUpload>;
} = {},
) {
const uploads = new Map(Object.entries(options.uploads ?? {}));
const pushes: {
items: number;
syncToken: string | null;
complete: boolean;
deleted?: number;
}[] = [];
const chunkBytes = options.chunkBytes ?? 4;
const api = {
media: {
sources: {
registerClient: vi.fn(async (input: { deviceId: string }) => ({
id: "source-1",
kind: "apple_photos" as const,
label: "Apple Photos",
status: "idle" as const,
assetCount: 0,
lastSyncedAt: null,
error: null,
createdAt: "2024-01-01T00:00:00.000Z",
deviceId: input.deviceId,
})),
pushItems: vi.fn(
async (input: {
items: unknown[];
deletedExternalIds?: string[];
syncToken: string | null;
complete: boolean;
}) => {
const deleted = input.deletedExternalIds?.length ?? 0;
expect(input.items.length > 0 || deleted > 0 || input.complete).toBe(true);
expect(input.items.length).toBeLessThanOrEqual(500);
pushes.push({
items: input.items.length,
syncToken: input.syncToken,
complete: input.complete,
...(deleted > 0 ? { deleted } : {}),
});
return { created: input.items.length, updated: 0, unchanged: 0, deleted };
},
),
},
assets: {
search: vi.fn(async (input: { cursor?: string | null }) => {
const all = options.assets ?? [];
// Two pages to exercise pagination.
const half = Math.ceil(all.length / 2);
return input.cursor === "p2"
? { items: all.slice(half), nextCursor: null }
: { items: all.slice(0, half), nextCursor: all.length > 1 ? "p2" : null };
}),
},
uploads: {
begin: vi.fn(async (input: { assetId: string; sizeBytes: number }) => {
const upload: MediaUpload = {
id: `upload-${input.assetId}`,
assetId: input.assetId,
sizeBytes: input.sizeBytes,
chunkBytes,
receivedChunks: [],
status: "open",
};
uploads.set(upload.id, upload);
return upload;
}),
status: vi.fn(async (input: { uploadId: string }) => {
const upload = uploads.get(input.uploadId);
if (!upload) throw Object.assign(new Error("Not found"), { code: "NOT_FOUND" });
return upload;
}),
complete: vi.fn(async (input: { uploadId: string }) => {
const upload = uploads.get(input.uploadId)!;
return { ...upload, status: "complete" as const };
}),
},
},
};
return { api: api as unknown as MediaSyncApi & typeof api, pushes };
}
let tempRoot: string;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "rakazo-photos-sync-"));
});
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
const baseOptions = () => ({
target: "https://rakazo.example.com#space_1",
label: "Apple Photos",
tempRoot,
signal: new AbortController().signal,
});
describe("Apple Photos metadata sync", () => {
it("registers once, pushes batches of at most 500 and completes the listing", async () => {
const { helper } = fakeHelper({ size: 1203 });
const { api, pushes } = fakeApi();
const { store, current } = memoryStore();
const chunks = vi.fn();
const result = await syncApplePhotos({
...baseOptions(),
helper,
api,
store,
uploadChunk: chunks,
});
expect(api.media.sources.registerClient).toHaveBeenCalledWith(
{ kind: "apple_photos", label: "Apple Photos", deviceId: "device-1" },
expect.anything(),
);
expect(pushes).toEqual([
{ items: 500, syncToken: null, complete: false },
{ items: 500, syncToken: null, complete: false },
{ items: 203, syncToken: "token-1203", complete: true },
]);
expect(result).toMatchObject({ indexed: 1203, created: 1203, uploaded: 0, fullListing: true });
expect(current().targets["https://rakazo.example.com#space_1"]).toEqual({
sourceId: "source-1",
listing: null,
changeToken: "token-1203",
uploads: {},
});
// The next run reads changes only and does not register again.
vi.mocked(helper.changes).mockResolvedValueOnce({
token: "token-next",
changed: [libraryItem(1), libraryItem(2)],
deleted: [],
fullResyncRequired: false,
});
const second = await syncApplePhotos({
...baseOptions(),
helper,
api,
store,
uploadChunk: chunks,
});
expect(api.media.sources.registerClient).toHaveBeenCalledTimes(1);
expect(helper.changes).toHaveBeenCalledWith("token-1203", expect.anything());
expect(pushes.at(-1)).toEqual({ items: 2, syncToken: "token-next", complete: false });
expect(second.fullListing).toBe(false);
expect(current().targets["https://rakazo.example.com#space_1"]?.changeToken).toBe("token-next");
});
it("persists the cursor after every batch and resumes an interrupted listing", async () => {
const { helper } = fakeHelper({ size: 1100 });
const { api, pushes } = fakeApi();
const { store, writes, current } = memoryStore();
vi.mocked(api.media.sources.pushItems).mockImplementationOnce(async (input) => {
pushes.push({
items: input.items.length,
syncToken: input.syncToken,
complete: input.complete,
});
return { created: input.items.length, updated: 0, unchanged: 0 };
});
vi.mocked(api.media.sources.pushItems).mockRejectedValueOnce(new Error("network down"));
await expect(
syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() }),
).rejects.toThrow("network down");
const interrupted = current().targets["https://rakazo.example.com#space_1"]!;
expect(interrupted.listing).toMatchObject({ cursor: "500", token: "token-1100" });
expect(interrupted.listing?.lastItem?.externalId).toBe(libraryItem(499).externalId);
expect(writes.length).toBeGreaterThan(1);
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() });
expect(vi.mocked(helper.list).mock.calls.at(-2)?.[0]).toMatchObject({ cursor: "500" });
expect(pushes.slice(1)).toEqual([
{ items: 500, syncToken: null, complete: false },
{ items: 100, syncToken: "token-1100", complete: true },
]);
expect(current().targets["https://rakazo.example.com#space_1"]).toMatchObject({
listing: null,
changeToken: "token-1100",
});
});
it("closes a resumed listing whose last page came back empty", async () => {
const { helper } = fakeHelper({ size: 0 });
const { api, pushes } = fakeApi();
const last = libraryItem(7);
const { store } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "source-1",
listing: { cursor: "500", token: "token-a", lastItem: last },
changeToken: null,
uploads: {},
},
},
});
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() });
expect(pushes).toEqual([{ items: 0, syncToken: "token-a", complete: true }]);
});
it("pushes device deletions without relisting", async () => {
const { helper } = fakeHelper({ size: 3 });
const { api, pushes } = fakeApi();
const { store } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "source-1",
listing: null,
changeToken: "old",
uploads: {},
},
},
});
vi.mocked(helper.changes).mockResolvedValueOnce({
token: "new",
changed: [],
deleted: ["gone/L0/001"],
fullResyncRequired: false,
});
const result = await syncApplePhotos({
...baseOptions(),
helper,
api,
store,
uploadChunk: vi.fn(),
});
expect(result.fullListing).toBe(false);
expect(pushes).toEqual([{ items: 0, syncToken: "new", complete: false, deleted: 1 }]);
});
it("registers again when the server no longer knows the source", async () => {
const { helper } = fakeHelper({ size: 2 });
const { api } = fakeApi();
const { store } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "deleted-source",
listing: { cursor: null, token: null, lastItem: null },
changeToken: null,
uploads: {},
},
},
});
vi.mocked(api.media.sources.pushItems).mockRejectedValueOnce(
Object.assign(new Error("Not found"), { code: "NOT_FOUND" }),
);
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk: vi.fn() });
expect(api.media.sources.registerClient).toHaveBeenCalledTimes(1);
expect(vi.mocked(api.media.sources.pushItems).mock.calls.at(-1)?.[0].sourceId).toBe("source-1");
});
it("stops before anything is sent when Photos access is denied", async () => {
const { helper } = fakeHelper({ size: 2 });
vi.mocked(helper.authorize).mockResolvedValueOnce("denied");
const { api } = fakeApi();
await expect(
syncApplePhotos({
...baseOptions(),
helper,
api,
store: memoryStore().store,
uploadChunk: vi.fn(),
}),
).rejects.toMatchObject({ code: "not_authorized" });
expect(api.media.sources.registerClient).not.toHaveBeenCalled();
});
});
describe("Apple Photos original upload", () => {
const awaitingAssets = [
asset("a1", "ASSET-00001/L0/001", "awaiting_upload"),
asset("a2", "ASSET-00002/L0/001", "proxied"),
asset("a3", "ASSET-00003/L0/001", "awaiting_upload"),
asset("a4", "ASSET-00004/L0/001", "awaiting_upload"),
];
it("uploads every awaiting original in chunks with two at a time and cleans up", async () => {
const { helper, exports, stats } = fakeHelper({ size: 1 });
const { api } = fakeApi({ assets: awaitingAssets, chunkBytes: 8 });
const sent = new Map<string, Buffer[]>();
const uploadChunk = vi.fn(async (uploadId: string, index: number, bytes: Uint8Array) => {
const parts = sent.get(uploadId) ?? [];
parts[index] = Buffer.from(bytes);
sent.set(uploadId, parts);
});
const progress = vi.fn();
const result = await syncApplePhotos({
...baseOptions(),
helper,
api,
store: memoryStore().store,
uploadChunk,
onProgress: progress,
});
expect(result.uploaded).toBe(3);
expect(
vi
.mocked(helper.exportOriginal)
.mock.calls.map((call) => call[0])
.sort(),
).toEqual(["ASSET-00001/L0/001", "ASSET-00003/L0/001", "ASSET-00004/L0/001"]);
expect(stats.maxActiveExports).toBe(2);
expect(Buffer.concat(sent.get("upload-a1")!).toString()).toBe(
"original bytes of ASSET-00001/L0/001",
);
expect(sent.get("upload-a1")).toHaveLength(Math.ceil(36 / 8));
expect(api.media.uploads.begin).toHaveBeenCalledWith(
expect.objectContaining({
assetId: "a1",
sizeBytes: 36,
sha256: sha("original bytes of ASSET-00001/L0/001"),
}),
expect.anything(),
);
expect(api.media.uploads.complete).toHaveBeenCalledTimes(3);
expect(progress).toHaveBeenLastCalledWith({
phase: "uploading",
indexed: 1,
uploadsDone: 3,
uploadsTotal: 3,
});
for (const dir of exports) await expect(readdir(dir)).rejects.toThrow();
await expect(readdir(tempRoot)).resolves.toEqual([]);
});
it("resumes an open upload and sends only the missing chunks", async () => {
const { helper } = fakeHelper({ size: 1 });
const contents = "original bytes of ASSET-00001/L0/001";
const { api } = fakeApi({
assets: [awaitingAssets[0]!],
uploads: {
"upload-old": {
id: "upload-old",
assetId: "a1",
sizeBytes: Buffer.byteLength(contents),
chunkBytes: 10,
receivedChunks: [0, 2],
status: "open",
},
},
});
const { store, current } = memoryStore({
version: 1,
deviceId: "device-1",
targets: {
"https://rakazo.example.com#space_1": {
sourceId: "source-1",
listing: null,
changeToken: "tok",
uploads: { a1: "upload-old" },
},
},
});
vi.mocked(helper.changes).mockResolvedValueOnce({
token: "tok",
changed: [],
deleted: [],
fullResyncRequired: false,
});
const uploadChunk = vi.fn(async () => {});
await syncApplePhotos({ ...baseOptions(), helper, api, store, uploadChunk });
expect(api.media.uploads.begin).not.toHaveBeenCalled();
expect(
uploadChunk.mock.calls.map((call) => [
call[0],
call[1],
Buffer.from(call[2] as Uint8Array).toString(),
]),
).toEqual([
["upload-old", 1, contents.slice(10, 20)],
["upload-old", 3, contents.slice(30)],
]);
expect(current().targets["https://rakazo.example.com#space_1"]?.uploads).toEqual({});
});
it("aborts on a checksum mismatch without starting an upload", async () => {
const { helper } = fakeHelper({ size: 1 });
vi.mocked(helper.exportOriginal).mockImplementationOnce(async (_id, destination) => {
const file = path.join(destination, "IMG.MOV");
await writeFile(file, "tampered");
return { path: file, fileName: "IMG.MOV", sizeBytes: 8, sha256: sha("original") };
});
const { api } = fakeApi({ assets: [awaitingAssets[0]!] });
const uploadChunk = vi.fn();
await expect(
syncApplePhotos({ ...baseOptions(), helper, api, store: memoryStore().store, uploadChunk }),
).rejects.toMatchObject({ code: "integrity" });
expect(api.media.uploads.begin).not.toHaveBeenCalled();
expect(uploadChunk).not.toHaveBeenCalled();
await expect(readdir(tempRoot)).resolves.toEqual([]);
});
it("cancels a running upload", async () => {
const { helper } = fakeHelper({ size: 1 });
const { api } = fakeApi({ assets: [awaitingAssets[0]!], chunkBytes: 4 });
const controller = new AbortController();
const uploadChunk = vi.fn(
async (_id: string, index: number, _bytes: Uint8Array, signal: AbortSignal) => {
if (index === 1) controller.abort();
signal.throwIfAborted();
},
);
await expect(
syncApplePhotos({
...baseOptions(),
signal: controller.signal,
helper,
api,
store: memoryStore().store,
uploadChunk,
}),
).rejects.toMatchObject({ code: "cancelled" });
expect(uploadChunk).toHaveBeenCalledTimes(2);
expect(api.media.uploads.complete).not.toHaveBeenCalled();
await expect(readdir(tempRoot)).resolves.toEqual([]);
});
});
describe("Apple Photos sync state file", () => {
it("keeps a stable device id across launches", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-state-"));
try {
const store = createPhotosSyncFileStore(dir, () => "device-a");
const first = await store.read();
expect(first.deviceId).toBe("device-a");
first.targets.x = { sourceId: "s", listing: null, changeToken: "t", uploads: {} };
await store.write(first);
const reopened = await createPhotosSyncFileStore(dir, () => "device-b").read();
expect(reopened).toEqual(first);
expect(await readFile(path.join(dir, "media-photos-sync.json"), "utf8")).toContain(
"device-a",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("drops unreadable state and malformed targets", () => {
expect(parsePhotosSyncState("{")).toBeNull();
expect(
parsePhotosSyncState(JSON.stringify({ version: 2, deviceId: "d", targets: {} })),
).toBeNull();
expect(
parsePhotosSyncState(
JSON.stringify({
version: 1,
deviceId: "d",
targets: {
good: { sourceId: "s", listing: null, changeToken: null, uploads: {} },
bad: { sourceId: 4, listing: null, changeToken: null, uploads: {} },
badListing: { sourceId: "s", listing: { cursor: 3 }, changeToken: null, uploads: {} },
},
}),
)?.targets,
).toEqual({ good: { sourceId: "s", listing: null, changeToken: null, uploads: {} } });
});
});
describe("chunk uploader", () => {
it("PUTs raw bytes with the session and space", async () => {
const fetch = vi.fn(async () => new Response(null, { status: 204 }));
const upload = createChunkUploader({
origin: "https://rakazo.example.com",
spaceId: "space_1",
fetch,
});
const signal = new AbortController().signal;
await upload("up/1", 3, new Uint8Array([1, 2, 3]), signal);
expect(fetch).toHaveBeenCalledWith(
"https://rakazo.example.com/api/media/uploads/up%2F1/chunks/3",
expect.objectContaining({
method: "PUT",
credentials: "include",
redirect: "error",
signal,
headers: { "content-type": "application/octet-stream", "x-rakazo-space-id": "space_1" },
}),
);
});
it("rejects failed chunks and non-origin targets", async () => {
const fetch = vi.fn(async () => new Response(null, { status: 409 }));
const upload = createChunkUploader({
origin: "https://rakazo.example.com",
spaceId: null,
fetch,
});
await expect(
upload("u", 0, new Uint8Array(), new AbortController().signal),
).rejects.toMatchObject({
code: "upload_failed",
});
expect(() =>
createChunkUploader({ origin: "https://rakazo.example.com/rpc", spaceId: null, fetch }),
).toThrow();
expect(() => createChunkUploader({ origin: "file:///etc", spaceId: null, fetch })).toThrow();
});
});
describe("runPool", () => {
it("bounds concurrency and reports the first failure", async () => {
let active = 0;
let max = 0;
const seen: number[] = [];
await expect(
runPool([1, 2, 3, 4, 5], 2, new AbortController().signal, async (item, signal) => {
active += 1;
max = Math.max(max, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
if (item === 2) throw new Error("boom");
signal.throwIfAborted();
seen.push(item);
}),
).rejects.toThrow("boom");
expect(max).toBe(2);
expect(seen).not.toContain(4);
});
});

View File

@ -0,0 +1,581 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdtemp, open, rm, stat } from "node:fs/promises";
import path from "node:path";
import type {
ClientMediaItem,
MediaAsset,
MediaUpload,
mediaRpcShapes,
PhotosSyncProgress,
PhotosSyncResult,
} from "@rakazo/contracts";
import type { PhotosHelper } from "./media-photos-helper.js";
import { PHOTOS_MAX_PAGE } from "./media-photos-helper.js";
import { readPrivateFile, writePrivateFile } from "./setup-store.js";
/*
* Apple Photos is a client-pushed source: the API may run on another machine, so the
* app never sends a local path. It pushes metadata in batches and uploads the originals
* the server asks for (`awaiting_upload`) in resumable raw-byte chunks.
*/
type Shapes = typeof mediaRpcShapes;
type Shape = { input: { _input: unknown }; output: { _output: unknown } };
/** One oRPC procedure as `ContractRouterClient<AppContract>` exposes it. */
type Procedure<S extends Shape> = (
input: S["input"]["_input"],
options?: { signal?: AbortSignal },
) => Promise<S["output"]["_output"]>;
/** The slice of the app RPC client the sync needs; `rpc` from a contract client satisfies it. */
export interface MediaSyncApi {
media: {
sources: {
registerClient: Procedure<Shapes["sources"]["registerClient"]>;
pushItems: Procedure<Shapes["sources"]["pushItems"]>;
};
assets: { search: Procedure<Shapes["assets"]["search"]> };
uploads: {
begin: Procedure<Shapes["uploads"]["begin"]>;
status: Procedure<Shapes["uploads"]["status"]>;
complete: Procedure<Shapes["uploads"]["complete"]>;
};
};
}
/** Sends one raw chunk to `PUT /api/media/uploads/:uploadId/chunks/:index`. */
export type UploadChunk = (
uploadId: string,
index: number,
bytes: Uint8Array<ArrayBuffer>,
signal: AbortSignal,
) => Promise<void>;
export type PhotosSyncErrorCode = "not_authorized" | "cancelled" | "integrity" | "upload_failed";
export class PhotosSyncError extends Error {
constructor(
readonly code: PhotosSyncErrorCode,
message: string,
) {
super(message);
this.name = "PhotosSyncError";
}
}
// ---------------------------------------------------------------------------
// Persistent state
// ---------------------------------------------------------------------------
export interface PhotosSyncTargetState {
sourceId: string;
/** A full listing in progress: where to resume, the token from its start and the last pushed item. */
listing: { cursor: string | null; token: string | null; lastItem: ClientMediaItem | null } | null;
/** Set after a complete listing; later syncs only read changes since it. */
changeToken: string | null;
/** Open uploads by asset id so an interrupted upload resumes instead of restarting. */
uploads: Record<string, string>;
}
export interface PhotosSyncState {
version: 1;
/** Stable per installation; the server keys the Apple Photos source by it. */
deviceId: string;
/** Keyed by server origin and space, since each has its own source. */
targets: Record<string, PhotosSyncTargetState>;
}
export interface PhotosSyncStore {
read(): Promise<PhotosSyncState>;
write(state: PhotosSyncState): Promise<void>;
}
export const PHOTOS_SYNC_FILE_NAME = "media-photos-sync.json";
const MAX_STATE_BYTES = 4 * 1024 * 1024;
/** Kept next to the desktop setup file, private to the user and written atomically. */
export function createPhotosSyncFileStore(
userDataDir: string,
newDeviceId: () => string = randomUUID,
): PhotosSyncStore {
const file = path.join(userDataDir, PHOTOS_SYNC_FILE_NAME);
return {
async read() {
const raw = await readPrivateFile(file, MAX_STATE_BYTES);
return (raw === null ? null : parsePhotosSyncState(raw)) ?? emptyState(newDeviceId());
},
write: (state) => writePrivateFile(file, `${JSON.stringify(state)}\n`),
};
}
function emptyState(deviceId: string): PhotosSyncState {
return { version: 1, deviceId, targets: {} };
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isNullableString = (value: unknown) => value === null || typeof value === "string";
/** Unreadable state starts over: the server deduplicates by device and external id. */
export function parsePhotosSyncState(raw: string): PhotosSyncState | null {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return null;
}
if (
!isRecord(value) ||
value.version !== 1 ||
typeof value.deviceId !== "string" ||
value.deviceId === "" ||
value.deviceId.length > 200 ||
!isRecord(value.targets)
) {
return null;
}
const targets: Record<string, PhotosSyncTargetState> = {};
for (const [key, target] of Object.entries(value.targets)) {
if (
!isRecord(target) ||
typeof target.sourceId !== "string" ||
!isNullableString(target.changeToken) ||
!isRecord(target.uploads) ||
!Object.values(target.uploads).every((id) => typeof id === "string")
) {
continue;
}
const listing = target.listing;
const validListing =
listing === null ||
(isRecord(listing) &&
isNullableString(listing.cursor) &&
isNullableString(listing.token) &&
(listing.lastItem === null || isRecord(listing.lastItem)));
if (!validListing) continue;
targets[key] = target as unknown as PhotosSyncTargetState;
}
return { version: 1, deviceId: value.deviceId, targets };
}
// ---------------------------------------------------------------------------
// Chunk upload over HTTP
// ---------------------------------------------------------------------------
/**
* Chunk PUTs ride the same signed-in session as the app window: the injected `fetch`
* must carry that session's cookies (Electron `session.fetch` with credentials).
*/
export function createChunkUploader(options: {
origin: string;
spaceId: string | null;
fetch: (url: string, init: RequestInit) => Promise<Response>;
}): UploadChunk {
const origin = new URL(options.origin);
if (!["http:", "https:"].includes(origin.protocol) || origin.origin !== options.origin) {
throw new Error("Uploads need the server origin.");
}
return async (uploadId, index, bytes, signal) => {
if (!Number.isSafeInteger(index) || index < 0) throw new Error("Invalid chunk index.");
const headers: Record<string, string> = { "content-type": "application/octet-stream" };
if (options.spaceId) headers["x-rakazo-space-id"] = options.spaceId;
const response = await options.fetch(
`${origin.origin}/api/media/uploads/${encodeURIComponent(uploadId)}/chunks/${index}`,
{
method: "PUT",
headers,
body: bytes,
credentials: "include",
redirect: "error",
signal,
},
);
if (!response.ok) {
throw new PhotosSyncError(
"upload_failed",
`The server rejected an upload (${response.status}).`,
);
}
};
}
// ---------------------------------------------------------------------------
// Sync
// ---------------------------------------------------------------------------
export type { PhotosSyncProgress, PhotosSyncResult };
export interface PhotosSyncOptions {
helper: PhotosHelper;
api: MediaSyncApi;
uploadChunk: UploadChunk;
store: PhotosSyncStore;
/** Server origin plus space, e.g. `https://rakazo.example.com#space_1`. */
target: string;
/** Source label shown in the library, e.g. the Mac's name or "Apple Photos". */
label: string;
/** Exports land in a fresh folder below this and are deleted after upload. */
tempRoot: string;
signal: AbortSignal;
onProgress?: (progress: PhotosSyncProgress) => void;
/** At most 500, the contract's batch limit. */
batchSize?: number;
concurrency?: number;
}
const MAX_SYNC_TOKEN_LENGTH = 2000;
export async function syncApplePhotos(options: PhotosSyncOptions): Promise<PhotosSyncResult> {
const { helper, api, signal } = options;
const batchSize = Math.min(Math.max(1, options.batchSize ?? PHOTOS_MAX_PAGE), PHOTOS_MAX_PAGE);
const result: PhotosSyncResult = {
indexed: 0,
created: 0,
updated: 0,
unchanged: 0,
uploaded: 0,
fullListing: false,
};
const progress = { uploadsDone: 0, uploadsTotal: 0 };
const report = (phase: PhotosSyncProgress["phase"]) =>
options.onProgress?.({ phase, indexed: result.indexed, ...progress });
const state = await options.store.read();
// Writes are serialized so concurrent upload workers never interleave partial state.
let saving = Promise.resolve();
const save = () => {
const snapshot = JSON.parse(JSON.stringify(state)) as PhotosSyncState;
saving = saving.then(() => options.store.write(snapshot));
return saving;
};
try {
report("authorizing");
const authorization = await helper.authorize(true, signal);
if (authorization !== "authorized" && authorization !== "limited") {
throw new PhotosSyncError("not_authorized", "Photos access was not granted.");
}
const ensureTarget = async (): Promise<PhotosSyncTargetState> => {
const existing = state.targets[options.target];
if (existing) return existing;
const source = await api.media.sources.registerClient(
{ kind: "apple_photos", label: options.label, deviceId: state.deviceId },
{ signal },
);
const created: PhotosSyncTargetState = {
sourceId: source.id,
listing: null,
changeToken: null,
uploads: {},
};
state.targets[options.target] = created;
await save();
return created;
};
const push = async (
target: PhotosSyncTargetState,
items: ClientMediaItem[],
syncToken: string | null,
complete: boolean,
deletedExternalIds: string[] = [],
) => {
const counts = await api.media.sources.pushItems(
{
sourceId: target.sourceId,
items,
deletedExternalIds,
syncToken:
syncToken !== null && syncToken.length <= MAX_SYNC_TOKEN_LENGTH ? syncToken : null,
complete,
},
{ signal },
);
result.indexed += items.length;
result.created += counts.created;
result.updated += counts.updated;
result.unchanged += counts.unchanged;
report("indexing");
};
/** Full listing, resumable page by page; the last push says `complete`. */
const listAll = async (target: PhotosSyncTargetState) => {
result.fullListing = true;
target.listing ??= { cursor: null, token: null, lastItem: null };
const listing = target.listing;
while (true) {
const page = await helper.list({ cursor: listing.cursor, limit: batchSize }, signal);
if (listing.cursor === null) listing.token = page.changeToken;
const done = page.nextCursor === null;
const items = page.items;
// An empty final page still closes the listing.
if (items.length > 0 || done) await push(target, items, done ? listing.token : null, done);
if (done) {
target.changeToken = listing.token;
target.listing = null;
await save();
return;
}
listing.cursor = page.nextCursor;
listing.lastItem = items.at(-1) ?? listing.lastItem;
await save();
}
};
const syncMetadata = async () => {
const target = await ensureTarget();
if (target.listing !== null || target.changeToken === null) {
await listAll(target);
return;
}
const changes = await helper.changes(target.changeToken, signal);
if (changes.fullResyncRequired || changes.token === null) {
target.listing = null;
await listAll(target);
return;
}
const batches = Math.max(
Math.ceil(changes.changed.length / batchSize),
Math.ceil(changes.deleted.length / DELETED_BATCH_SIZE),
);
for (let index = 0; index < batches; index++) {
const batch = changes.changed.slice(index * batchSize, (index + 1) * batchSize);
const deleted = changes.deleted.slice(
index * DELETED_BATCH_SIZE,
(index + 1) * DELETED_BATCH_SIZE,
);
const last = index === batches - 1;
await push(target, batch, last ? changes.token : null, false, deleted);
}
target.changeToken = changes.token;
await save();
};
report("indexing");
try {
await syncMetadata();
} catch (error) {
// The source was removed on the server: register again and list from scratch.
if (!isNotFound(error) || signal.aborted) throw error;
delete state.targets[options.target];
await save();
await syncMetadata();
}
const target = state.targets[options.target]!;
const awaiting = await awaitingUploads(api, target.sourceId, signal);
progress.uploadsTotal = awaiting.length;
// Forget uploads for assets the server no longer waits for.
const waiting = new Set(awaiting.map((asset) => asset.id));
for (const assetId of Object.keys(target.uploads)) {
if (!waiting.has(assetId)) delete target.uploads[assetId];
}
await save();
report("uploading");
await runPool(awaiting, options.concurrency ?? 2, signal, async (asset, workerSignal) => {
await uploadAsset({ ...options, signal: workerSignal }, target, asset, save);
result.uploaded += 1;
progress.uploadsDone += 1;
report("uploading");
});
return result;
} catch (error) {
if (signal.aborted) throw new PhotosSyncError("cancelled", "Sync cancelled.");
throw error;
} finally {
await saving.catch(() => undefined);
}
}
/**
* Binds the sync to the server and space the request names. `connect` builds the RPC
* client and chunk uploader on the signed-in session for that space.
*/
export function createPhotosSyncRunner(deps: {
helper: PhotosHelper;
store: PhotosSyncStore;
tempRoot: string;
label: string;
connect: (
spaceId: string | null,
) => Promise<{ origin: string; api: MediaSyncApi; uploadChunk: UploadChunk }>;
}) {
return async (request: {
spaceId: string | null;
signal: AbortSignal;
onProgress: (progress: PhotosSyncProgress) => void;
}): Promise<PhotosSyncResult> => {
const { origin, api, uploadChunk } = await deps.connect(request.spaceId);
return syncApplePhotos({
helper: deps.helper,
store: deps.store,
tempRoot: deps.tempRoot,
label: deps.label,
api,
uploadChunk,
target: `${origin}#${request.spaceId ?? "default"}`,
signal: request.signal,
onProgress: request.onProgress,
});
};
}
function isNotFound(error: unknown): boolean {
return isRecord(error) && error.code === "NOT_FOUND";
}
interface AwaitingAsset {
id: string;
externalId: string;
}
/** Assets of this source the server is waiting for, mapped back to Photos items. */
const DELETED_BATCH_SIZE = 5000;
export async function awaitingUploads(
api: MediaSyncApi,
sourceId: string,
signal: AbortSignal,
): Promise<AwaitingAsset[]> {
const awaiting: AwaitingAsset[] = [];
let cursor: string | null = null;
do {
const page: { items: MediaAsset[]; nextCursor: string | null } = await api.media.assets.search(
{ sourceId, ingestStates: ["awaiting_upload"], cursor, limit: PHOTOS_MAX_PAGE },
{ signal },
);
for (const asset of page.items) {
if (asset.ingestState !== "awaiting_upload" || asset.sourceId !== sourceId) continue;
awaiting.push({ id: asset.id, externalId: asset.externalId });
}
cursor = page.nextCursor;
} while (cursor !== null);
return awaiting;
}
async function uploadAsset(
options: PhotosSyncOptions,
target: PhotosSyncTargetState,
asset: AwaitingAsset,
save: () => Promise<void>,
): Promise<void> {
const { api, signal } = options;
const directory = await mkdtemp(path.join(options.tempRoot, "rakazo-photos-"));
try {
const exported = await options.helper.exportOriginal(asset.externalId, directory, { signal });
// The helper's hash is checked against the bytes on disk before anything is sent.
const local = await hashFile(exported.path, signal);
if (local.sha256 !== exported.sha256 || local.sizeBytes !== exported.sizeBytes) {
throw new PhotosSyncError("integrity", "The exported file does not match its checksum.");
}
let upload: MediaUpload | null = null;
const previous = target.uploads[asset.id];
if (previous) {
const status = await api.media.uploads
.status({ uploadId: previous }, { signal })
.catch((error: unknown) => {
if (isNotFound(error)) return null;
throw error;
});
if (status?.status === "complete") {
delete target.uploads[asset.id];
await save();
return;
}
if (status?.status === "open" && status.sizeBytes === local.sizeBytes) upload = status;
}
if (upload === null) {
upload = await api.media.uploads.begin(
{
assetId: asset.id,
sizeBytes: local.sizeBytes,
sha256: local.sha256,
fileName: exported.fileName.slice(0, 255),
},
{ signal },
);
target.uploads[asset.id] = upload.id;
await save();
}
const received = new Set(upload.receivedChunks);
const chunkCount = Math.ceil(local.sizeBytes / upload.chunkBytes);
const file = await open(exported.path, "r");
try {
for (let index = 0; index < chunkCount; index += 1) {
if (received.has(index)) continue;
signal.throwIfAborted();
const offset = index * upload.chunkBytes;
const length = Math.min(upload.chunkBytes, local.sizeBytes - offset);
const bytes = Buffer.alloc(length);
const { bytesRead } = await file.read(bytes, 0, length, offset);
if (bytesRead !== length) {
throw new PhotosSyncError("integrity", "The exported file changed during upload.");
}
await options.uploadChunk(upload.id, index, bytes, signal);
}
} finally {
await file.close();
}
const completed = await api.media.uploads.complete({ uploadId: upload.id }, { signal });
if (completed.status !== "complete") {
throw new PhotosSyncError("upload_failed", "The server did not accept the upload.");
}
delete target.uploads[asset.id];
await save();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
export async function hashFile(
file: string,
signal?: AbortSignal,
): Promise<{ sha256: string; sizeBytes: number }> {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file, { signal })) hash.update(chunk as Buffer);
return { sha256: hash.digest("hex"), sizeBytes: (await stat(file)).size };
}
/**
* Runs `work` with at most `limit` items in flight. The first failure aborts the
* others and is the error reported.
*/
export async function runPool<T>(
items: T[],
limit: number,
signal: AbortSignal,
work: (item: T, signal: AbortSignal) => Promise<void>,
): Promise<void> {
const controller = new AbortController();
const forward = () => controller.abort(signal.reason);
signal.addEventListener("abort", forward, { once: true });
if (signal.aborted) forward();
let next = 0;
let failed = false;
let failure: unknown;
const worker = async () => {
while (!failed && !controller.signal.aborted && next < items.length) {
const item = items[next++]!;
try {
await work(item, controller.signal);
} catch (error) {
if (!failed) {
failed = true;
failure = error;
controller.abort(error);
}
}
}
};
try {
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
} finally {
signal.removeEventListener("abort", forward);
}
if (failed) throw failure;
signal.throwIfAborted();
}

View File

@ -9,6 +9,18 @@ contextBridge.exposeInMainWorld("rakazoDesktop", {
request: (pathname, body) =>
ipcRenderer.invoke("desktop.localSettings.request", pathname, body),
},
media: {
photos: {
status: () => ipcRenderer.invoke("desktop.media.photos.status"),
sync: (request) => ipcRenderer.invoke("desktop.media.photos.sync", request),
cancel: () => ipcRenderer.invoke("desktop.media.photos.cancel"),
onProgress: (listener) => {
const handler = (_event, progress) => listener(progress);
ipcRenderer.on("desktop.media.photos.progress", handler);
return () => ipcRenderer.off("desktop.media.photos.progress", handler);
},
},
},
window: {
close: () => ipcRenderer.invoke("desktop.window.close"),
minimize: () => ipcRenderer.invoke("desktop.window.minimize"),

View File

@ -35,6 +35,7 @@ describe("desktop preload bridge", () => {
"browserPreview",
"files",
"localSettings",
"media",
"oauth",
"platform",
"update",
@ -47,6 +48,12 @@ describe("desktop preload bridge", () => {
"toggleMaximize",
]);
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
expect(Object.keys(bridge.media?.photos ?? {}).sort()).toEqual([
"cancel",
"onProgress",
"status",
"sync",
]);
expect(Object.keys(bridge.browserPreview ?? {}).sort()).toEqual([
"back",
"forward",
@ -97,6 +104,7 @@ describe("desktop preload bridge", () => {
"browserPreview",
"files",
"localSettings",
"media",
"oauth",
"platform",
"update",

View File

@ -35,6 +35,7 @@
"expo-image-picker": "57.0.20",
"expo-linking": "57.0.11",
"expo-localization": "57.0.2",
"expo-media-library": "57.0.5",
"expo-modules-core": "57.0.20",
"expo-notifications": "57.0.21",
"expo-router": "57.0.24",
@ -44,6 +45,7 @@
"expo-status-bar": "57.0.1",
"expo-symbols": "57.0.3",
"expo-updates": "57.0.24",
"expo-video": "57.0.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",

View File

@ -0,0 +1,432 @@
import type { Page, Request } from "@playwright/test";
/*
* Scripted Media Studio backend for the project and review specs. No RPC can
* seed an emulator library into a fresh space, so these specs answer the media
* procedures they use with synthetic, schema-shaped data and record every call.
*/
export const PROJECT_ID = "proj-e2e";
export const SHORTS = "youtube-shorts";
export const PAID = "example-paid-video";
export const TIKTOK = "tiktok";
const at = (minute: number) => new Date(Date.UTC(2026, 9, 1, 10, minute)).toISOString();
function clip(id: string, assetId: string, inMs: number, outMs: number, atMs: number) {
return {
id,
assetId,
inMs,
outMs,
atMs,
speed: 1,
reframe: "auto",
stabilize: false,
muted: false,
};
}
export function edl(profileId: string, width: number, height: number) {
return {
edlVersion: 1,
output: {
profileId,
width,
height,
fps: 30,
durationTargetMs: 12_000,
durationHardMinMs: 3_000,
durationHardMaxMs: 60_000,
},
tracks: {
video: [
clip("clip-1", "asset-1", 0, 4_000, 0),
clip("clip-2", "asset-2", 0, 4_000, 4_000),
clip("clip-3", "asset-3", 1_000, 4_000, 8_000),
],
audio: [],
},
transitions: [],
captions: { mode: "burned", maxCharsPerLine: 32, wordHighlight: false, position: "lower" },
overlays: [{ id: "text-1", kind: "text", atMs: 500, durationMs: 2_000, text: "Wait for it" }],
redactions: {
faces: [
{
id: "face-1",
target: { personId: "person-a" },
mode: "partial_eyes",
paddingPct: 20,
featherPct: 10,
minSizePct: 2,
},
],
marks: [{ markId: "mark-1", treatment: "cover" }],
regions: [],
},
metadata: { title: "Teaser", tags: [], aiAltered: false, aiAlterations: [] },
};
}
function plan(id: string, profileId: string, version: number, extra: Record<string, unknown> = {}) {
const [width, height] = profileId === PAID ? [1920, 1080] : [1080, 1920];
return {
id,
projectId: PROJECT_ID,
profileId,
version,
edl: edl(profileId, width, height),
createdBy: "user",
rationale: null,
createdAt: at(version),
...extra,
};
}
const report = (profileId: string, issues: unknown[], passed: boolean, autoFixRound = 0) => ({
profileId,
passed,
checkedAt: at(30),
sampledFrames: 48,
issues,
autoFixRound,
});
function render(id: string, extra: Record<string, unknown>) {
return {
id,
planId: "plan-shorts-1",
projectId: PROJECT_ID,
profileId: SHORTS,
quality: "draft",
status: "done",
progress: 1,
outputs: [
{ kind: "video", artifactId: null, fileName: "video.mp4", sizeBytes: 1_000_000 },
{ kind: "poster", artifactId: "art-poster", fileName: "poster.jpg", sizeBytes: 20_000 },
],
gateReport: null,
ffprobe: null,
error: null,
createdAt: at(20),
updatedAt: at(21),
...extra,
};
}
export const BLOCKED_ISSUES = [
{
check: "anonymization",
severity: "block",
code: "mask_coverage",
message: "A face of Alex is not fully covered.",
atMs: 5_000,
endMs: 5_600,
personId: "person-a",
},
{
check: "content",
severity: "block",
code: "classifier_threshold",
message: "Explicit nudity above the profile limit.",
atMs: 2_000,
category: "explicit_nudity",
score: 0.9,
},
{
check: "consent",
severity: "block",
code: "consent_missing",
message: "Sam has no recorded consent.",
atMs: 9_000,
personId: "person-b",
},
{
check: "anonymization",
severity: "warn",
code: "mark_residual",
message: "The tattoo is faintly visible.",
atMs: 10_000,
endMs: 10_400,
markId: "mark-1",
},
{
check: "text",
severity: "warn",
code: "hashtags",
message: "Fewer hashtags would read better.",
},
];
/** Mutable backend state the mock answers from; specs read `calls` to assert requests. */
export function mediaBackend() {
const plans: Record<string, unknown>[] = [
plan("plan-shorts-1", SHORTS, 1),
plan("plan-paid-1", PAID, 1),
plan("plan-paid-2", PAID, 2, {
createdBy: "agent",
rationale: "Auto-fix 1/3 after the gate blocked (mask_coverage): widened the face mask",
}),
];
const renders: Record<string, unknown>[] = [
render("render-shorts-draft", { gateReport: report(SHORTS, [], true) }),
render("render-paid-draft", {
planId: "plan-paid-1",
profileId: PAID,
status: "blocked",
outputs: [],
gateReport: report(PAID, BLOCKED_ISSUES, false, 1),
}),
];
const people = [
{
id: "person-a",
displayName: "Alex",
isSelf: false,
confirmed: true,
anonymizeByDefault: "partial_eyes",
consentRecordedAt: at(1),
consentArtifactId: null,
ageVerifiedAt: at(1),
ageVerificationMethod: "platform_verified",
faceTrackCount: 3,
coverThumbAssetId: null,
},
{
id: "person-b",
displayName: "Sam",
isSelf: false,
confirmed: true,
anonymizeByDefault: "blur",
consentRecordedAt: null,
consentArtifactId: null,
ageVerifiedAt: null,
ageVerificationMethod: "none",
faceTrackCount: 1,
coverThumbAssetId: null,
},
];
const marks = [
{
id: "mark-1",
kind: "tattoo",
label: null,
personId: "person-a",
assetId: "asset-2",
prompt: { tMs: 1_000, point: { x: 0.4, y: 0.6 } },
treatment: "inpaint",
qualityScore: 0.42,
suggestedFallback: "cover",
createdAt: at(2),
},
];
const calls: Array<{ procedure: string; input: Record<string, unknown> }> = [];
/** Submissions answered with these errors first, then accepted. */
const submitErrors: unknown[][] = [];
let renderCounter = 0;
let pollsUntilDone = 0;
const project = {
id: PROJECT_ID,
title: "Harbor teaser",
goal: "teaser",
status: "review",
brandKitId: null,
collectionIds: [],
profileIds: [SHORTS, PAID, TIKTOK],
createdAt: at(0),
updatedAt: at(0),
};
function advanceRenders() {
for (const item of renders) {
if (item.status !== "queued" && item.status !== "rendering") continue;
if (pollsUntilDone > 0) {
pollsUntilDone -= 1;
item.status = "rendering";
item.progress = 0.5;
continue;
}
item.status = "done";
item.progress = 1;
item.outputs = [
{ kind: "video", artifactId: null, fileName: "video.mp4", sizeBytes: 2_000_000 },
];
item.gateReport = report(String(item.profileId), [], true);
}
}
const profiles = [
[SHORTS, "YouTube Shorts", "free_social", false],
[PAID, "Example paid video", "paid_video", false],
[TIKTOK, "TikTok", "free_social", true],
].map(([id, name, kind, unverified]) => ({
id,
name,
kind,
origin: "builtin",
unverified,
stale: false,
aspectRatios: id === PAID ? ["16:9"] : ["9:16"],
hardMinMs: 1_000,
hardMaxMs: 60_000,
recommendedMs: [15_000],
}));
const handlers: Record<string, (input: Record<string, unknown>) => unknown> = {
capabilities: () => ({
configured: true,
operations: {},
accelerator: "cpu",
hardwareEncoders: [],
}),
"profiles/list": () => profiles,
"projects/get": () => {
advanceRenders();
return { project, plans, renders };
},
"projects/autoPlan": () => {
const created = plan("plan-tiktok-1", TIKTOK, 1, { createdBy: "agent" });
plans.push(created);
return [created];
},
"assets/get": (input) => ({
asset: {
id: input.assetId,
sourceId: "source-1",
externalId: `${String(input.assetId)}.mp4`,
kind: "video",
storage: "referenced",
capturedAt: at(0),
durationMs: 6_000,
width: 1920,
height: 1080,
fps: 30,
gpsLat: null,
gpsLon: null,
deviceModel: null,
albumNames: [],
isFavorite: false,
ingestState: "analyzed",
hasProxy: true,
hasThumbnail: true,
qualityScore: 0.8,
createdAt: at(0),
},
segments: [],
faceTracks: [],
transcript: null,
}),
"plans/submit": (input) => {
const errors = submitErrors.shift();
if (errors) return { ok: false, errors };
const base = plans.find((item) => item.profileId === (input.edl as { output: { profileId: string } }).output.profileId);
const version = plans.filter((item) => item.profileId === base?.profileId).length + 1;
const created = {
...plan(`plan-saved-${version}`, String(base?.profileId), version),
edl: input.edl,
};
plans.push(created);
return { ok: true, plan: created };
},
"renders/start": (input) => {
const source = plans.find((item) => item.id === input.planId);
renderCounter += 1;
const created = render(`render-new-${renderCounter}`, {
planId: input.planId,
profileId: source?.profileId,
quality: input.quality,
status: "queued",
progress: 0,
outputs: [],
createdAt: at(40 + renderCounter),
});
renders.push(created);
return created;
},
"renders/get": (input) => {
advanceRenders();
return renders.find((item) => item.id === input.renderId);
},
"renders/thumbnails": () =>
["a", "b", "c"].map((letter) => ({
kind: "thumbnail",
artifactId: `art-thumb-${letter}`,
fileName: `thumb-${letter}.png`,
sizeBytes: 10_000,
})),
"marketing/copy": (input) => ({
profileId: SHORTS,
language: input.language,
title: "Harbor at dawn",
hook: "You have never seen the harbor like this",
caption: "Three minutes before sunrise.",
hashtags: ["#harbor", "#sunrise"],
issues: [],
}),
"people/list": () => people,
"people/update": (input) => {
const person = people.find((item) => item.id === input.personId);
if (person && typeof input.anonymizeByDefault === "string") {
person.anonymizeByDefault = input.anonymizeByDefault;
}
return person;
},
"marks/list": () => marks,
"marks/update": (input) => {
const mark = marks.find((item) => item.id === input.markId);
if (mark && typeof input.treatment === "string") mark.treatment = input.treatment;
return mark;
},
"marks/create": (input) => {
const created = {
id: `mark-${marks.length + 1}`,
kind: input.kind,
label: null,
personId: null,
assetId: input.assetId,
prompt: input.prompt,
treatment: input.treatment,
qualityScore: 0.9,
suggestedFallback: null,
createdAt: at(50),
};
marks.push(created as (typeof marks)[number]);
return created;
},
};
return {
calls,
submitErrors,
/** Let a started render report `rendering` for this many polls before it finishes. */
holdRenders(polls: number) {
pollsUntilDone = polls;
},
async install(page: Page) {
await page.route("**/rpc/media/**", async (route) => {
const request: Request = route.request();
const procedure = new URL(request.url()).pathname.replace(/^.*\/rpc\/media\//, "");
const handler = handlers[procedure];
const body = (request.postDataJSON() ?? {}) as { json?: Record<string, unknown> };
const input = body.json ?? {};
calls.push({ procedure, input });
if (!handler) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ json: { message: `unmocked ${procedure}` } }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ json: handler(input) }),
});
});
// Byte routes: no synthetic media files are needed for these flows.
await page.route("**/api/media/**", (route) => route.fulfill({ status: 404, body: "" }));
},
};
}

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Media editor test</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/e2e/fixtures/media-editor.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,58 @@
import { setupI18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { useState } from "react";
import { createRoot } from "react-dom/client";
import { BrandKitView } from "../../src/pages/media/BrandKitView";
import { EditorView } from "../../src/pages/media/EditorView";
import type { MediaView } from "../../src/pages/media/nav";
import { ProjectView } from "../../src/pages/media/ProjectView";
import { ReviewView } from "../../src/pages/media/ReviewView";
import "../../src/styles.css";
/*
* Mounts the Media Studio editor-side views on their own, starting at the view
* named in the query string, so specs reach them without the library flow.
* Views the editor side does not own render as a marker the spec can assert.
*/
const params = new URLSearchParams(location.search);
const i18n = setupI18n({ locale: "en", messages: { en: {} } });
function initialView(): MediaView {
const projectId = params.get("projectId") ?? "";
switch (params.get("view")) {
case "editor":
return { name: "editor", projectId, planId: params.get("planId") ?? "" };
case "review":
return { name: "review", projectId, renderId: params.get("renderId") ?? "" };
case "brandKit":
return { name: "brandKit", brandKitId: params.get("brandKitId") ?? undefined };
default:
return { name: "project", projectId };
}
}
function Harness() {
const [view, setView] = useState<MediaView>(initialView);
const props = { navigate: setView, onClose: () => undefined };
switch (view.name) {
case "project":
return <ProjectView {...props} projectId={view.projectId} />;
case "editor":
return <EditorView {...props} projectId={view.projectId} planId={view.planId} />;
case "review":
return <ReviewView {...props} projectId={view.projectId} renderId={view.renderId} />;
case "brandKit":
return <BrandKitView {...props} brandKitId={view.brandKitId} />;
default:
return <div data-testid="media-other-view" data-view={view.name} />;
}
}
createRoot(document.getElementById("root")!).render(
<I18nProvider i18n={i18n}>
<main className="min-h-screen bg-background text-foreground">
<Harness />
</main>
</I18nProvider>,
);

View File

@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
test.setTimeout(180_000);
test("brand kit editor saves a kit and the intro generator offers three variants", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `media-brand-${stamp}@rakazo.test`, "password12", "Brand Kit");
await completeOnboarding(page);
// The scripted runtime runs Media Studio on the inference emulator.
const capabilities = await rpc<{ configured: boolean }>(page, "media/capabilities", {});
expect(capabilities.configured).toBe(true);
await page.goto("/e2e/fixtures/media-editor.html?view=brandKit");
const editor = page.getByTestId("media-brand-kit");
await expect(editor).toBeVisible();
// A new kit has nothing to generate an intro from until it is saved.
await expect(editor.getByTestId("media-intro-generator")).toHaveCount(0);
await editor.getByLabel("Name").fill("Studio Nord");
await editor.getByLabel("Tone").fill("calm, precise");
await editor.getByLabel("Primary").fill("#1f4e79");
await editor.getByRole("switch", { name: "Watermark" }).click();
await editor.getByLabel("Position").selectOption("top_right");
await editor.getByRole("button", { name: "Caption style" }).click();
await editor.getByLabel("Look").selectOption("karaoke");
await editor.getByRole("button", { name: "Save" }).click();
const generator = editor.getByTestId("media-intro-generator");
await expect(generator).toBeVisible();
await expect(editor.getByRole("button", { name: "Save" })).toBeDisabled();
await expect(editor.getByRole("heading", { name: "Studio Nord" })).toBeVisible();
await captureScreenshot(page, testInfo, "media-brand-kit");
await generator.getByRole("button", { name: "Suggest" }).click();
const variants = generator.getByTestId("media-intro-variant");
await expect(variants).toHaveCount(3, { timeout: 120_000 });
await captureScreenshot(page, testInfo, "media-brand-kit-intros");
await variants.first().getByRole("button", { name: "Use" }).click();
await expect(variants.first().getByText("In use")).toBeVisible();
await expect(variants.nth(1).getByRole("button", { name: "Use" })).toBeVisible();
// The intro is stored on the kit.
const kits = await rpc<Array<{ kit: { name: string; intro?: { templateId: string } } }>>(
page,
"media/brandKits/list",
{},
);
const saved = kits.find((record) => record.kit.name === "Studio Nord");
expect(saved?.kit.intro?.templateId).toBeTruthy();
// Back lists the kits.
await editor.getByRole("button", { name: "Back" }).click();
const list = page.getByTestId("media-brand-kits");
await expect(list.getByText("Studio Nord")).toBeVisible();
await captureScreenshot(page, testInfo, "media-brand-kit-list");
});

View File

@ -0,0 +1,144 @@
import { expect, test } from "@playwright/test";
import type { edl } from "./fixtures/media-editor-data";
import { mediaBackend, PAID, PROJECT_ID, SHORTS, TIKTOK } from "./fixtures/media-editor-data";
import { captureScreenshot } from "./helpers";
const projectUrl = `/e2e/fixtures/media-editor.html?view=project&projectId=${PROJECT_ID}`;
test("project variants plan, approve into a final render, and publish", async ({
page,
}, testInfo) => {
const backend = mediaBackend();
await backend.install(page);
await page.goto(projectUrl);
const project = page.getByTestId("media-project");
await expect(project.getByRole("heading", { name: "Harbor teaser" })).toBeVisible();
const shorts = page.locator(`[data-testid="media-variant"][data-profile="${SHORTS}"]`);
const paid = page.locator(`[data-testid="media-variant"][data-profile="${PAID}"]`);
const tiktok = page.locator(`[data-testid="media-variant"][data-profile="${TIKTOK}"]`);
await expect(shorts.getByTestId("media-gate-status")).toHaveAttribute("data-status", "pass");
await expect(paid.getByTestId("media-gate-status")).toHaveAttribute("data-status", "blocked");
await expect(paid.getByText("Auto-fix 1/3")).toBeVisible();
await expect(tiktok.getByText("No plan yet")).toBeVisible();
await expect(tiktok.getByText("Unverified")).toBeVisible();
// A blocked draft cannot be approved.
await expect(paid.getByRole("button", { name: "Approve" })).toHaveCount(0);
await captureScreenshot(page, testInfo, "media-project-variants");
// Auto-plan fills the profile without a plan.
await project.getByRole("button", { name: "Plan" }).click();
await expect(tiktok.getByRole("button", { name: "Edit" })).toBeVisible();
await expect(project.getByRole("button", { name: "Plan" })).toHaveCount(0);
// Approving a passed draft starts the final render; the card polls until it is done.
backend.holdRenders(2);
await shorts.getByRole("button", { name: "Approve" }).click();
await expect(shorts.getByText(/Rendering \d+%/)).toBeVisible();
const start = backend.calls.find((call) => call.procedure === "renders/start");
expect(start?.input).toEqual({ planId: "plan-shorts-1", quality: "final" });
await expect(shorts.getByTestId("media-publish")).toBeVisible({ timeout: 15_000 });
await expect(shorts.getByRole("button", { name: "Download" })).toBeVisible();
// Polling stops once nothing renders.
const polls = backend.calls.filter((call) => call.procedure === "projects/get").length;
await page.waitForTimeout(2_500);
expect(backend.calls.filter((call) => call.procedure === "projects/get").length).toBe(polls);
// Thumbnails, copy, and the bundle step appear in order.
const publish = shorts.getByTestId("media-publish");
await publish.getByRole("button", { name: "Thumbnails" }).click();
await expect(publish.getByRole("button", { name: /thumb-/ })).toHaveCount(3);
await expect(publish.getByRole("button", { name: "thumb-a" })).toHaveAttribute(
"aria-pressed",
"true",
);
await publish.getByRole("button", { name: "thumb-b" }).click();
await publish.getByRole("button", { name: "Write copy" }).click();
await expect(publish.getByRole("textbox", { name: "Title" })).toHaveValue("Harbor at dawn");
await expect(publish.getByRole("textbox", { name: "Hashtags" })).toHaveValue(
"#harbor #sunrise",
);
await expect(publish.getByRole("button", { name: "Bundle" })).toBeVisible();
await captureScreenshot(page, testInfo, "media-project-publish");
});
test("the editor trims and moves clips, edits captions and overlays, and shows EDL errors", async ({
page,
}, testInfo) => {
const backend = mediaBackend();
await backend.install(page);
backend.submitErrors.push([
{
code: "overlap",
path: "tracks.video[1].atMs",
message: "clip-2 overlaps clip-3 on the video track",
},
{ code: "duration_above_max", path: "output", message: "duration is above the limit" },
]);
await page.goto(projectUrl);
const shorts = page.locator(`[data-testid="media-variant"][data-profile="${SHORTS}"]`);
await shorts.getByRole("button", { name: "Edit" }).click();
const editor = page.getByTestId("media-editor");
await expect(editor).toBeVisible();
const clips = editor.getByTestId("media-clip");
await expect(clips).toHaveCount(3);
await expect(editor.getByRole("button", { name: "Save" })).toBeDisabled();
// Trim the first clip's end to the right with the pointer.
const trimEnd = clips.first().getByTestId("media-trim-end");
const handle = await trimEnd.boundingBox();
if (!handle) throw new Error("trim handle not laid out");
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2);
await page.mouse.down();
await page.mouse.move(handle.x + 40, handle.y + handle.height / 2, { steps: 5 });
await page.mouse.move(handle.x + 60, handle.y + handle.height / 2, { steps: 5 });
await page.mouse.up();
// Move the third clip later.
const third = clips.nth(2).getByRole("button", { name: "Clip clip-3" });
const box = await third.boundingBox();
if (!box) throw new Error("clip not laid out");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 30, box.y + box.height / 2, { steps: 5 });
await page.mouse.move(box.x + box.width / 2 + 50, box.y + box.height / 2, { steps: 5 });
await page.mouse.up();
// Captions: switch to the subtitle file and highlight words.
await editor.getByRole("button", { name: "Captions", exact: true }).click();
const inspector = editor.getByTestId("media-inspector");
await inspector.getByLabel("Captions").selectOption("both");
await inspector.getByRole("switch", { name: "Highlight words" }).click();
// A new text overlay at the playhead.
await editor.getByRole("button", { name: "Text", exact: true }).click();
await inspector.getByLabel("Text").fill("Link in bio");
await editor.getByRole("button", { name: "Save" }).click();
const errors = editor.getByTestId("media-edl-errors");
await expect(errors.getByText("clip-2 overlaps clip-3 on the video track")).toBeVisible();
await expect(errors.getByText("duration is above the limit")).toBeVisible();
await expect(clips.nth(1)).toHaveAttribute("data-invalid", "true");
await captureScreenshot(page, testInfo, "media-editor-errors");
const submitted = backend.calls.find((call) => call.procedure === "plans/submit")?.input as {
projectId: string;
edl: ReturnType<typeof edl>;
};
expect(submitted.projectId).toBe(PROJECT_ID);
const [first, , moved] = submitted.edl.tracks.video;
expect(first?.outMs).toBeGreaterThan(4_000);
expect(first?.outMs).toBeLessThanOrEqual(6_000);
expect(moved?.atMs).toBeGreaterThan(8_000);
expect(submitted.edl.captions).toMatchObject({ mode: "both", wordHighlight: true });
expect(submitted.edl.overlays.at(-1)).toMatchObject({ kind: "text", text: "Link in bio" });
// Selecting an error selects the clip; the next save is accepted as a new version.
await errors.getByText("clip-2 overlaps clip-3 on the video track").click();
await expect(inspector.getByRole("spinbutton", { name: "In", exact: true })).toBeVisible();
await editor.getByRole("button", { name: "Save" }).click();
await expect(editor.getByRole("heading", { name: `${SHORTS} · v2` })).toBeVisible();
await expect(errors).toHaveCount(0);
await captureScreenshot(page, testInfo, "media-editor");
});

View File

@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
import { mediaBackend, PROJECT_ID } from "./fixtures/media-editor-data";
import { captureScreenshot } from "./helpers";
const reviewUrl = (renderId: string) =>
`/e2e/fixtures/media-editor.html?view=review&projectId=${PROJECT_ID}&renderId=${renderId}`;
test("review lists gate blockers that jump the player and shows coverage and auto-fix", async ({
page,
}, testInfo) => {
const backend = mediaBackend();
await backend.install(page);
await page.goto(reviewUrl("render-paid-draft"));
const review = page.getByTestId("media-review");
await expect(review.getByTestId("media-gate-status")).toHaveAttribute("data-status", "blocked");
await expect(review.getByTestId("media-autofix")).toContainText("Auto-fix 1/3");
await expect(review.getByTestId("media-autofix")).toContainText("widened the face mask");
// Issues in timeline order, untimed last.
const issues = review.getByTestId("media-gate-issue");
await expect(issues).toHaveCount(5);
await expect(issues.nth(0)).toContainText("0:02.0");
await expect(issues.nth(0)).toContainText("Explicit nudity above the profile limit.");
await expect(issues.nth(1)).toContainText("0:05.0");
await expect(issues.nth(4)).toContainText("Fewer hashtags would read better.");
await expect(issues.nth(4).getByRole("button")).toBeDisabled();
// A blocked render has no output; the plan plays from the sources.
const player = review.getByTestId("media-source-player");
await expect(review.getByRole("button", { name: "After" })).toHaveCount(0);
await issues.nth(1).getByRole("button").click();
await expect(player).toHaveAttribute("data-position-ms", "5000");
await issues.nth(2).getByRole("button").click();
await expect(player).toHaveAttribute("data-position-ms", "9000");
// Coverage per person and mark; a segment seeks too.
const coverage = review.getByTestId("media-coverage");
await expect(coverage.getByText("Alex")).toBeVisible();
await expect(coverage.getByText("Sam")).toBeVisible();
await expect(coverage.getByText("Tattoo")).toBeVisible();
await coverage.getByRole("button", { name: /0:10\.0/ }).click();
await expect(player).toHaveAttribute("data-position-ms", "10000");
await captureScreenshot(page, testInfo, "media-review");
});
test("review edits people, marks, and masks, then re-renders the draft", async ({
page,
}, testInfo) => {
const backend = mediaBackend();
await backend.install(page);
await page.goto(reviewUrl("render-paid-draft"));
const review = page.getByTestId("media-review");
await expect(review.getByTestId("media-gate-issue")).toHaveCount(5);
// Per person: anonymization mode; consent only where the gate asks for it.
const people = review.getByTestId("media-review-person");
await expect(people).toHaveCount(2);
await review.getByLabel("Anonymization for Alex").selectOption("pixelate");
await expect
.poll(() => backend.calls.find((call) => call.procedure === "people/update")?.input)
.toEqual({ personId: "person-a", anonymizeByDefault: "pixelate" });
await expect(people.nth(0).getByRole("button", { name: "Record consent" })).toHaveCount(0);
await expect(people.nth(1).getByRole("button", { name: "Record consent" })).toBeVisible();
// Per mark: a low quality score offers the suggested fallback.
const mark = review.getByTestId("media-review-mark");
await expect(mark).toContainText("42%");
await mark.getByRole("button", { name: "Use Cover" }).click();
await expect(mark.getByLabel("Treatment")).toHaveValue("cover");
await expect(mark.getByRole("button", { name: "Use Cover" })).toHaveCount(0);
// Mark a spot on the frame.
await review.getByRole("button", { name: "Mark", exact: true }).click();
await review.getByRole("button", { name: "Mark this spot" }).click({ position: { x: 20, y: 20 } });
await expect
.poll(() => backend.calls.find((call) => call.procedure === "marks/create")?.input)
.toMatchObject({ kind: "custom", assetId: "asset-1", treatment: "blur" });
// Draw a mask and drag it.
await review.getByRole("button", { name: "Mask", exact: true }).click();
const mask = review.getByTestId("media-mask");
await expect(mask).toBeVisible();
const box = await mask.boundingBox();
if (!box) throw new Error("mask not laid out");
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 40, box.y + box.height / 2 + 10, { steps: 4 });
await page.mouse.up();
await captureScreenshot(page, testInfo, "media-review-mask");
await review.getByRole("button", { name: "Save" }).click();
await expect(review.getByRole("button", { name: "Draft" })).toBeVisible();
const submitted = backend.calls.find((call) => call.procedure === "plans/submit")?.input as {
edl: {
redactions: {
regions: Array<{ clipId: string; keyframes: Array<{ box: { x: number } }> }>;
};
};
};
const region = submitted.edl.redactions.regions[0];
expect(region?.clipId).toBe("clip-1");
expect(region?.keyframes[0]?.box.x).toBeGreaterThan(0.4);
// Re-rendering opens the new render, which polls while it runs.
backend.holdRenders(1);
await review.getByRole("button", { name: "Draft" }).click();
await expect(review.getByText(/Rendering \d+%/)).toBeVisible();
await expect(review.getByTestId("media-gate-status")).toHaveAttribute("data-status", "pass", {
timeout: 15_000,
});
// Consent is recorded on the people screen, never here.
await page.goto(reviewUrl("render-paid-draft"));
await page.getByRole("button", { name: "Record consent" }).click();
await expect(page.getByTestId("media-other-view")).toHaveAttribute("data-view", "people");
});

View File

@ -24,6 +24,7 @@
"@rakazo/core": "workspace:*",
"@rakazo/ui-tokens": "workspace:*",
"@rakazo/ui-web": "workspace:*",
"@tanstack/react-virtual": "3.14.13",
"better-auth": "1.7.4",
"lucide-react": "1.44.0",
"react": "19.2.3",

View File

@ -0,0 +1,214 @@
import type { Edl } from "@rakazo/contracts";
import { cn } from "@rakazo/ui-web";
import { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { assetFileUrl, useMediaObjectUrl } from "./media-files";
import { clipLengthMs, clipOutputStartMs, edlDurationMs, locateOutputTime } from "./timeline";
export interface PlayerHandle {
/** Jump to an output timestamp in ms. */
seek(ms: number): void;
}
/** What the source player currently shows, in source coordinates. */
export interface SourceFrame {
outputMs: number;
clipId: string;
assetId: string;
sourceMs: number;
}
/** Plays a finished render. Reports the playhead in output ms. */
export function RenderVideo({
src,
poster,
handleRef,
onTime,
className,
label,
}: {
src: string | null;
poster?: string | null;
handleRef?: React.Ref<PlayerHandle>;
onTime?: (ms: number) => void;
className?: string;
label: string;
}) {
const video = useRef<HTMLVideoElement>(null);
const pending = useRef<number | null>(null);
useImperativeHandle(handleRef, () => ({
seek(ms) {
const element = video.current;
if (!element || element.readyState < 1) {
pending.current = ms;
return;
}
element.currentTime = ms / 1000;
onTime?.(ms);
},
}));
return (
// biome-ignore lint/a11y/useMediaCaption: renders carry burned-in or sidecar captions.
<video
ref={video}
aria-label={label}
className={cn("aspect-video w-full rounded-lg bg-muted object-contain", className)}
controls
playsInline
preload="metadata"
poster={poster ?? undefined}
src={src ?? undefined}
onLoadedMetadata={(event) => {
if (pending.current === null) return;
event.currentTarget.currentTime = pending.current / 1000;
pending.current = null;
}}
onTimeUpdate={(event) => onTime?.(Math.round(event.currentTarget.currentTime * 1000))}
/>
);
}
/**
* Plays a plan's timeline from the source proxies (the unredacted "before"
* view, and the only view of a render the gate blocked). Clips play in order;
* gaps and the intro show nothing.
*/
export function SourcePlayer({
edl,
handleRef,
onFrame,
label,
children,
}: {
edl: Edl;
handleRef?: React.Ref<PlayerHandle>;
onFrame?: (frame: SourceFrame | null) => void;
label: string;
/** Overlays positioned over the video frame (masks, mark prompts). */
children?: React.ReactNode;
}) {
const [outputMs, setOutputMs] = useState(() => clipOutputStartMs(edl, firstClip(edl)));
const located = locateOutputTime(edl, outputMs);
const assetId = located?.clip.assetId ?? null;
const src = useMediaObjectUrl(assetId ? assetFileUrl(assetId, "proxy") : null);
const video = useRef<HTMLVideoElement>(null);
const playing = useRef(false);
const [ratio, setRatio] = useState(16 / 9);
const report = useCallback(
(ms: number) => {
const at = locateOutputTime(edl, ms);
onFrame?.(
at
? { outputMs: ms, clipId: at.clip.id, assetId: at.clip.assetId, sourceMs: at.sourceMs }
: null,
);
},
[edl, onFrame],
);
const seek = useCallback(
(ms: number) => {
const clamped = Math.max(0, Math.min(ms, edlDurationMs(edl)));
setOutputMs(clamped);
const at = locateOutputTime(edl, clamped);
const element = video.current;
if (at && element && at.clip.assetId === assetId && element.readyState >= 1) {
element.currentTime = at.sourceMs / 1000;
}
report(clamped);
},
[edl, assetId, report],
);
useImperativeHandle(handleRef, () => ({ seek }), [seek]);
// A new clip's proxy loaded: start at the mapped source time.
const onLoaded = (element: HTMLVideoElement) => {
if (element.videoWidth > 0 && element.videoHeight > 0) {
setRatio(element.videoWidth / element.videoHeight);
}
if (located) element.currentTime = located.sourceMs / 1000;
if (playing.current) void element.play().catch(() => undefined);
};
const onTimeUpdate = (element: HTMLVideoElement) => {
if (!located) return;
const { clip } = located;
const sourceMs = element.currentTime * 1000;
const clipEnd = clipOutputStartMs(edl, clip) + clipLengthMs(clip);
if (sourceMs >= clip.outMs) {
const next = nextClipStart(edl, clipEnd);
if (next === null) {
element.pause();
playing.current = false;
return;
}
setOutputMs(next);
report(next);
return;
}
const ms = clipOutputStartMs(edl, clip) + Math.round((sourceMs - clip.inMs) / clip.speed);
setOutputMs(ms);
report(ms);
};
// Report once per plan; playback reports through onTimeUpdate.
useEffect(() => {
report(outputMs);
}, [edl]);
return (
<div
className="relative aspect-video w-full overflow-hidden rounded-lg bg-muted"
data-testid="media-source-player"
data-position-ms={outputMs}
>
{/* biome-ignore lint/a11y/useMediaCaption: source footage has no caption track. */}
<video
ref={video}
aria-label={label}
className="absolute inset-0 h-full w-full object-contain"
controls
playsInline
preload="auto"
src={src ?? undefined}
onLoadedMetadata={(event) => onLoaded(event.currentTarget)}
onPlay={() => {
playing.current = true;
}}
onPause={() => {
playing.current = false;
}}
onTimeUpdate={(event) => onTimeUpdate(event.currentTarget)}
/>
{children ? (
// Children get the displayed frame, so normalized boxes map onto the picture.
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
data-frame
className="relative"
style={{
aspectRatio: ratio,
...(ratio >= 16 / 9 ? { width: "100%" } : { height: "100%" }),
}}
>
{children}
</div>
</div>
) : null}
</div>
);
}
function firstClip(edl: Edl) {
return [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0] ?? edl.tracks.video[0]!;
}
/** Output start of the first clip beginning at or after `ms`, or null. */
function nextClipStart(edl: Edl, ms: number): number | null {
let best: number | null = null;
for (const clip of edl.tracks.video) {
const start = clipOutputStartMs(edl, clip);
if (start >= ms && (best === null || start < best)) best = start;
}
return best;
}

View File

@ -0,0 +1,191 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type {
MarketingCopy,
MediaPublication,
MediaRender,
MediaRenderOutput,
} from "@rakazo/contracts";
import { Button, cn, Input, Textarea } from "@rakazo/ui-web";
import { Download } from "lucide-react";
import { useState } from "react";
import { rpc } from "../../../lib/rpc";
import { LoadingState } from "../../ai/primitives";
import { ErrorLine, errorMessage } from "./chrome";
import {
bundleUrl,
downloadMediaFile,
mediaArtifactUrl,
renderFileUrl,
useMediaObjectUrl,
} from "./media-files";
import { thumbnailVariant } from "./renders";
/**
* Thumbnails, copy, and the publication bundle for a final render that passed
* its gate. Each step appears once the one before it has produced something.
*/
export function PublishPanel({ render }: { render: MediaRender }) {
const { t, i18n } = useLingui();
const [thumbs, setThumbs] = useState<MediaRenderOutput[]>(() =>
render.outputs.filter((output) => output.kind === "thumbnail"),
);
const [chosen, setChosen] = useState<string | null>(null);
const [copy, setCopy] = useState<MarketingCopy | null>(null);
const [title, setTitle] = useState("");
const [caption, setCaption] = useState("");
const [tags, setTags] = useState("");
const [publication, setPublication] = useState<MediaPublication | null>(null);
const [busy, setBusy] = useState<"thumbs" | "copy" | "bundle" | null>(null);
const [error, setError] = useState<string | null>(null);
async function run(step: "thumbs" | "copy" | "bundle", work: () => Promise<void>) {
setBusy(step);
setError(null);
try {
await work();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(null);
}
}
const makeThumbs = () =>
run("thumbs", async () => {
const outputs = await rpc.media.renders.thumbnails({ renderId: render.id, count: 3 });
setThumbs(outputs);
setChosen(outputs[0] ? thumbnailVariant(outputs[0]) : null);
});
const makeCopy = () =>
run("copy", async () => {
const next = await rpc.media.marketing.copy({
renderId: render.id,
language: i18n.locale.slice(0, 2) || "en",
});
setCopy(next);
setTitle(next.title);
setCaption([next.hook, next.caption].filter(Boolean).join("\n\n"));
setTags(next.hashtags.join(" "));
});
const download = (bundled: MediaPublication) =>
downloadMediaFile(
bundled.bundleArtifactId ? mediaArtifactUrl(bundled.bundleArtifactId) : bundleUrl(bundled.id),
`bundle-${bundled.id}.zip`,
);
const makeBundle = () =>
run("bundle", async () => {
if (publication) {
await download(publication);
return;
}
const created = await rpc.media.publications.create({
renderId: render.id,
title: title.trim() || null,
caption: caption.trim() || null,
tags: tags
.split(/\s+/)
.map((tag) => tag.trim())
.filter(Boolean),
thumbnailVariant: chosen,
});
const bundled = await rpc.media.publications.bundle({ publicationId: created.id });
setPublication(bundled);
await download(bundled);
});
return (
<section className="flex flex-col gap-3" data-testid="media-publish">
{thumbs.length === 0 ? (
<Button variant="outline" size="sm" disabled={busy !== null} onClick={makeThumbs}>
{busy === "thumbs" ? <LoadingState label={t`Thumbnails`} /> : <Trans>Thumbnails</Trans>}
</Button>
) : (
<div className="grid grid-cols-3 gap-2">
{thumbs.map((output) => {
const variant = thumbnailVariant(output);
return (
<Thumbnail
key={output.fileName}
src={renderFileUrl(render.id, output.fileName)}
selected={chosen === variant}
label={variant}
onSelect={() => setChosen(variant)}
/>
);
})}
</div>
)}
{copy ? (
<div className="flex flex-col gap-2">
<Input aria-label={t`Title`} value={title} onChange={(e) => setTitle(e.target.value)} />
<Textarea
aria-label={t`Caption`}
rows={4}
value={caption}
onChange={(e) => setCaption(e.target.value)}
/>
<Input aria-label={t`Hashtags`} value={tags} onChange={(e) => setTags(e.target.value)} />
{copy.issues.map((issue) => (
<p
key={`${issue.code}-${issue.message}`}
className={cn(
"text-ui-body-sm",
issue.severity === "block" ? "text-destructive" : "text-warning",
)}
>
{issue.message}
</p>
))}
</div>
) : (
<Button variant="outline" size="sm" disabled={busy !== null} onClick={makeCopy}>
{busy === "copy" ? <LoadingState label={t`Copy`} /> : <Trans>Write copy</Trans>}
</Button>
)}
<Button size="sm" disabled={busy !== null} onClick={makeBundle}>
{busy === "bundle" ? (
<LoadingState label={t`Bundle`} />
) : (
<>
<Download />
{publication ? <Trans>Download again</Trans> : <Trans>Bundle</Trans>}
</>
)}
</Button>
<ErrorLine error={error} />
</section>
);
}
function Thumbnail({
src,
selected,
label,
onSelect,
}: {
src: string;
selected: boolean;
label: string;
onSelect: () => void;
}) {
const url = useMediaObjectUrl(src);
return (
<button
type="button"
aria-pressed={selected}
aria-label={label}
onClick={onSelect}
className={cn(
"aspect-video overflow-hidden rounded-md border bg-muted",
selected ? "border-primary ring-2 ring-ring/50" : "border-border",
)}
>
{url ? <img src={url} alt="" className="h-full w-full object-cover" /> : null}
</button>
);
}

View File

@ -0,0 +1,321 @@
import type { DragEndEvent, DragMoveEvent } from "@dnd-kit/core";
import {
DndContext,
KeyboardSensor,
PointerSensor,
useDraggable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { useLingui } from "@lingui/react/macro";
import type { Edl } from "@rakazo/contracts";
import { cn } from "@rakazo/ui-web";
import { useEffect, useRef, useState } from "react";
import type { DragId } from "./timeline";
import {
applyDrag,
clipLengthMs,
edlDurationMs,
formatMs,
introMs,
mainProgramMs,
} from "./timeline";
export type TimelineSelection =
| { kind: "clip"; id: string }
| { kind: "overlay"; id: string }
| { kind: "captions" }
| null;
/**
* Tracks of a plan: video clips (move, trim), overlays (move), audio, and
* captions. Positions are output time; the intro occupies the start.
*/
export function Timeline({
edl,
assetDurations,
selection,
invalid,
playheadMs,
onSelect,
onSeek,
onChange,
}: {
edl: Edl;
assetDurations: ReadonlyMap<string, number | null>;
selection: TimelineSelection;
/** Clip and overlay ids the last save rejected. */
invalid: ReadonlySet<string>;
playheadMs: number;
onSelect: (selection: TimelineSelection) => void;
onSeek: (ms: number) => void;
onChange: (edl: Edl) => void;
}) {
const { t } = useLingui();
const container = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(800);
const [drag, setDrag] = useState<{ id: DragId; deltaMs: number } | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 3 } }),
useSensor(KeyboardSensor),
);
useEffect(() => {
const element = container.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
if (entry) setWidth(entry.contentRect.width);
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const shown = drag ? applyDrag(edl, drag.id, drag.deltaMs, assetDurations) : edl;
const totalMs = Math.max(5000, Math.round(edlDurationMs(edl) * 1.15));
const pxPerMs = Math.max(0.005, width / totalMs);
const offset = introMs(shown);
const x = (ms: number) => ms * pxPerMs;
const toMs = (event: DragMoveEvent | DragEndEvent) => Math.round(event.delta.x / pxPerMs);
return (
<DndContext
sensors={sensors}
onDragMove={(event) => setDrag({ id: event.active.id as DragId, deltaMs: toMs(event) })}
onDragCancel={() => setDrag(null)}
onDragEnd={(event) => {
setDrag(null);
const deltaMs = toMs(event);
if (deltaMs !== 0) {
onChange(applyDrag(edl, event.active.id as DragId, deltaMs, assetDurations));
}
}}
>
<div
ref={container}
className="relative flex select-none flex-col gap-1 overflow-hidden rounded-lg border border-border bg-card p-2"
data-testid="media-timeline"
>
<button
type="button"
aria-label={t`Timeline position`}
className="relative h-5 w-full cursor-pointer text-left text-ui-caption text-muted-foreground"
onClick={(event) => {
const box = event.currentTarget.getBoundingClientRect();
onSeek(Math.round((event.clientX - box.left) / pxPerMs));
}}
>
{ticks(totalMs).map((ms) => (
<span key={ms} className="absolute top-0" style={{ left: x(ms) }}>
{formatMs(ms)}
</span>
))}
</button>
<Track label={t`Video`}>
{shown.intro ? (
<div
className="absolute inset-y-0 rounded-md bg-muted"
style={{ left: 0, width: x(shown.intro.durationMs) }}
/>
) : null}
{shown.tracks.video.map((clip) => (
<ClipBlock
key={clip.id}
id={clip.id}
left={x(offset + clip.atMs)}
width={x(clipLengthMs(clip))}
selected={selection?.kind === "clip" && selection.id === clip.id}
invalid={invalid.has(clip.id)}
label={t`Clip ${clip.id}`}
onSelect={() => onSelect({ kind: "clip", id: clip.id })}
/>
))}
</Track>
<Track label={t`Overlays`}>
{shown.overlays.map((overlay) => (
<OverlayBlock
key={overlay.id}
id={overlay.id}
left={x(offset + overlay.atMs)}
width={x(overlay.durationMs)}
text={overlay.text ?? overlay.kind}
selected={selection?.kind === "overlay" && selection.id === overlay.id}
invalid={invalid.has(overlay.id)}
onSelect={() => onSelect({ kind: "overlay", id: overlay.id })}
/>
))}
</Track>
{shown.tracks.audio.length > 0 ? (
<Track label={t`Audio`}>
{shown.tracks.audio.map((clip) => (
<div
key={clip.id}
className="absolute inset-y-1 rounded-md bg-secondary"
style={{
left: x(offset + clip.atMs),
width: x(Math.max(0, clip.outMs - clip.inMs)),
}}
/>
))}
</Track>
) : null}
<Track label={t`Captions`}>
<button
type="button"
aria-pressed={selection?.kind === "captions"}
onClick={() => onSelect({ kind: "captions" })}
className={cn(
"absolute inset-y-1 rounded-md border text-left text-ui-caption",
shown.captions && shown.captions.mode !== "none"
? "border-border bg-accent px-2 text-accent-foreground"
: "border-dashed border-border px-2 text-muted-foreground",
selection?.kind === "captions" && "ring-2 ring-ring",
)}
style={{ left: x(offset), width: Math.max(x(mainProgramMs(shown)), 48) }}
>
{t`Captions`}
</button>
</Track>
<div
aria-hidden
className="pointer-events-none absolute top-2 bottom-2 w-px bg-primary"
style={{ left: 8 + x(playheadMs) }}
/>
</div>
</DndContext>
);
}
function Track({ label, children }: { label: string; children: React.ReactNode }) {
return (
// biome-ignore lint/a11y/useSemanticElements: a timeline track is a labelled group of blocks.
<div role="group" aria-label={label} className="relative h-10 rounded-md bg-muted/40">
{children}
</div>
);
}
function ClipBlock({
id,
left,
width,
selected,
invalid,
label,
onSelect,
}: {
id: string;
left: number;
width: number;
selected: boolean;
invalid: boolean;
label: string;
onSelect: () => void;
}) {
const { t } = useLingui();
const move = useDraggable({ id: `clip:${id}` });
const start = useDraggable({ id: `trim-start:${id}` });
const end = useDraggable({ id: `trim-end:${id}` });
const handle = "h-full w-2 shrink-0 cursor-ew-resize bg-primary/40 focus-visible:bg-primary";
return (
<div
data-testid="media-clip"
data-clip-id={id}
data-invalid={invalid || undefined}
className={cn(
"absolute inset-y-1 flex items-stretch overflow-hidden rounded-md border bg-primary/15 text-ui-caption text-foreground",
selected ? "border-primary ring-2 ring-ring/50" : "border-primary/30",
invalid && "border-destructive ring-2 ring-destructive/40",
move.isDragging && "opacity-80",
)}
style={{ left, width: Math.max(width, 8) }}
>
<button
type="button"
ref={start.setNodeRef}
{...start.attributes}
{...start.listeners}
aria-label={t`Trim start`}
data-testid="media-trim-start"
className={handle}
/>
<button
type="button"
ref={move.setNodeRef}
{...move.attributes}
{...move.listeners}
aria-label={label}
aria-pressed={selected}
onClick={onSelect}
className={cn(
"min-w-0 flex-1 cursor-grab truncate px-1 text-left",
move.isDragging && "cursor-grabbing",
)}
>
{id}
</button>
<button
type="button"
ref={end.setNodeRef}
{...end.attributes}
{...end.listeners}
aria-label={t`Trim end`}
data-testid="media-trim-end"
className={handle}
/>
</div>
);
}
function OverlayBlock({
id,
left,
width,
text,
selected,
invalid,
onSelect,
}: {
id: string;
left: number;
width: number;
text: string;
selected: boolean;
invalid: boolean;
onSelect: () => void;
}) {
const move = useDraggable({ id: `overlay:${id}` });
return (
<button
type="button"
ref={move.setNodeRef}
{...move.attributes}
{...move.listeners}
aria-label={text}
aria-pressed={selected}
data-testid="media-overlay"
onClick={onSelect}
className={cn(
"absolute inset-y-1 cursor-grab truncate rounded-md border bg-secondary px-1 text-left text-ui-caption text-secondary-foreground",
selected ? "border-primary ring-2 ring-ring/50" : "border-border",
invalid && "border-destructive ring-2 ring-destructive/40",
)}
style={{ left, width: Math.max(width, 8) }}
>
{text}
</button>
);
}
/** Ruler marks every 1, 5, 10, or 30 seconds, whichever keeps about eight labels. */
function ticks(totalMs: number): number[] {
const step = [1000, 5000, 10_000, 30_000, 60_000].find((ms) => totalMs / ms <= 10) ?? 120_000;
const out: number[] = [];
for (let ms = 0; ms < totalMs; ms += step) out.push(ms);
return out;
}

View File

@ -0,0 +1,60 @@
import { useLingui } from "@lingui/react/macro";
import { Badge, Button, cn } from "@rakazo/ui-web";
import { ChevronLeft } from "lucide-react";
import type { GateStatus } from "./renders";
/** Header row of an editor-side view: back, title, and the view's actions. */
export function ViewHeader({
title,
onBack,
backLabel,
children,
}: {
title: React.ReactNode;
onBack: () => void;
backLabel: string;
children?: React.ReactNode;
}) {
return (
<header className="flex min-h-control-lg items-center gap-2">
<Button variant="ghost" size="icon-sm" aria-label={backLabel} onClick={onBack}>
<ChevronLeft />
</Button>
<h2 className="min-w-0 flex-1 truncate text-ui-title font-semibold">{title}</h2>
{children}
</header>
);
}
/** Gate outcome as a small status badge. */
export function GateBadge({ status }: { status: GateStatus }) {
const { t } = useLingui();
const label = { pass: t`Passed`, warn: t`Warnings`, blocked: t`Blocked` }[status];
return (
<Badge
data-testid="media-gate-status"
data-status={status}
variant={status === "blocked" ? "destructive" : "outline"}
className={cn(
status === "pass" && "border-success/40 text-success",
status === "warn" && "border-warning/40 text-warning",
)}
>
{label}
</Badge>
);
}
/** A request error in one line, under the control that caused it. */
export function ErrorLine({ error }: { error: string | null }) {
if (!error) return null;
return (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
);
}
export function errorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : String(error);
}

View File

@ -0,0 +1,147 @@
import { Field, FieldLabel, Input, NativeSelect, NativeSelectOption, Switch } from "@rakazo/ui-web";
import { useEffect, useId, useState } from "react";
/*
* Compact labelled inputs for the inspector panels. Times are edited in
* seconds and stored in milliseconds.
*/
export function SecondsField({
label,
ms,
min = 0,
onChange,
}: {
label: string;
ms: number;
min?: number;
onChange: (ms: number) => void;
}) {
const id = useId();
const [text, setText] = useState(() => (ms / 1000).toFixed(1));
useEffect(() => setText((ms / 1000).toFixed(1)), [ms]);
return (
<Field className="gap-1">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
id={id}
type="number"
inputMode="decimal"
step={0.1}
min={min / 1000}
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
const seconds = Number(text);
if (Number.isFinite(seconds) && seconds * 1000 >= min)
onChange(Math.round(seconds * 1000));
else setText((ms / 1000).toFixed(1));
}}
/>
</Field>
);
}
export function NumberField({
label,
value,
min,
max,
step = 1,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}) {
const id = useId();
return (
<Field className="gap-1">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
id={id}
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(event) => {
const next = Number(event.target.value);
if (Number.isFinite(next)) onChange(Math.min(max, Math.max(min, next)));
}}
/>
</Field>
);
}
export function SelectField<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: ReadonlyArray<{ value: T; label: string }>;
onChange: (value: T) => void;
}) {
const id = useId();
return (
<Field className="gap-1">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<NativeSelect id={id} value={value} onChange={(event) => onChange(event.target.value as T)}>
{options.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
);
}
export function TextField({
label,
value,
maxLength,
onChange,
}: {
label: string;
value: string;
maxLength?: number;
onChange: (value: string) => void;
}) {
const id = useId();
return (
<Field className="gap-1">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
id={id}
value={value}
maxLength={maxLength}
onChange={(event) => onChange(event.target.value)}
/>
</Field>
);
}
export function ToggleField({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
const id = useId();
return (
<Field orientation="horizontal" className="items-center gap-2">
<Switch id={id} checked={checked} onCheckedChange={onChange} />
<FieldLabel htmlFor={id}>{label}</FieldLabel>
</Field>
);
}

View File

@ -0,0 +1,93 @@
import type { MediaRender } from "@rakazo/contracts";
import { useEffect, useRef, useState } from "react";
import { withSpaceHeaders } from "../../../lib/rpc";
/*
* Media byte routes need the selected space header, which `<video src>` and
* `<img src>` cannot send. Files are fetched with the header and shown through
* object URLs; renders and previews are short, so whole-file fetches are fine.
*/
export function renderFileUrl(renderId: string, fileName: string, download = false): string {
const base = `/api/media/renders/${encodeURIComponent(renderId)}/files/${encodeURIComponent(fileName)}`;
return download ? `${base}?download=1` : base;
}
export function assetFileUrl(assetId: string, kind: "thumb" | "proxy"): string {
return `/api/media/assets/${encodeURIComponent(assetId)}/${kind}`;
}
/** Thumbnails, intro previews, and other media outputs stored as space artifacts. */
export function mediaArtifactUrl(artifactId: string): string {
return `/api/media/artifacts/${encodeURIComponent(artifactId)}`;
}
export function bundleUrl(publicationId: string): string {
return `/api/media/bundles/${encodeURIComponent(publicationId)}`;
}
/** The streamable video of a render, when it has one. */
export function renderVideoUrl(render: Pick<MediaRender, "id" | "outputs">): string | null {
const video = render.outputs.find((output) => output.kind === "video");
return video ? renderFileUrl(render.id, video.fileName) : null;
}
export function renderPosterUrl(render: Pick<MediaRender, "id" | "outputs">): string | null {
const poster = render.outputs.find((output) => output.kind === "poster");
return poster ? renderFileUrl(render.id, poster.fileName) : null;
}
/** An object URL for a media byte route, or null while loading or when missing. */
export function useMediaObjectUrl(url: string | null): string | null {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
useEffect(() => {
setObjectUrl(null);
if (!url) return;
const controller = new AbortController();
let created: string | null = null;
void fetch(url, {
headers: withSpaceHeaders(),
credentials: "include",
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) return;
const blob = await response.blob();
if (controller.signal.aborted) return;
created = URL.createObjectURL(blob);
setObjectUrl(created);
})
.catch(() => undefined);
return () => {
controller.abort();
if (created) URL.revokeObjectURL(created);
};
}, [url]);
return objectUrl;
}
/** Download a media byte route as a file. */
export async function downloadMediaFile(url: string, fileName: string): Promise<void> {
const response = await fetch(url, { headers: withSpaceHeaders(), credentials: "include" });
if (!response.ok) throw new Error(`Download failed (${response.status})`);
const href = URL.createObjectURL(await response.blob());
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(href), 60_000);
}
/**
* Run `tick` every `intervalMs` while `active`; stops as soon as work is idle.
* The latest `tick` is always used, so callers need not memoize it.
*/
export function usePolling(tick: () => unknown, active: boolean, intervalMs: number): void {
const latest = useRef(tick);
latest.current = tick;
useEffect(() => {
if (!active) return;
const timer = window.setInterval(() => void latest.current(), intervalMs);
return () => window.clearInterval(timer);
}, [active, intervalMs]);
}

View File

@ -0,0 +1,135 @@
import type { GateIssue, MediaEditPlan, MediaRender } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import {
gateStatus,
projectVariants,
reviewedPlan,
sortedIssues,
thumbnailVariant,
} from "./renders";
function render(patch: Partial<MediaRender>): MediaRender {
return {
id: "r1",
planId: "p1",
projectId: "proj",
profileId: "youtube-shorts",
quality: "draft",
status: "done",
progress: 1,
outputs: [],
gateReport: null,
ffprobe: null,
error: null,
createdAt: "2026-10-01T10:00:00.000Z",
updatedAt: "2026-10-01T10:00:00.000Z",
...patch,
};
}
function plan(patch: Partial<MediaEditPlan>): MediaEditPlan {
return {
id: "p1",
projectId: "proj",
profileId: "youtube-shorts",
version: 1,
edl: {} as MediaEditPlan["edl"],
createdBy: "user",
rationale: null,
createdAt: "2026-10-01T09:00:00.000Z",
...patch,
};
}
const issue = (patch: Partial<GateIssue>): GateIssue => ({
check: "content",
severity: "block",
code: "x",
message: "m",
...patch,
});
const report = (issues: GateIssue[], passed: boolean, autoFixRound = 0) => ({
profileId: "youtube-shorts",
passed,
checkedAt: "2026-10-01T10:00:00.000Z",
sampledFrames: 10,
issues,
autoFixRound,
});
describe("gateStatus", () => {
it("is null while running or without a report", () => {
expect(gateStatus(render({ status: "rendering" }))).toBeNull();
expect(gateStatus(render({ gateReport: null }))).toBeNull();
});
it("separates pass, warnings, and blocked", () => {
expect(gateStatus(render({ gateReport: report([], true) }))).toBe("pass");
expect(gateStatus(render({ gateReport: report([issue({ severity: "warn" })], true) }))).toBe(
"warn",
);
expect(gateStatus(render({ status: "blocked", gateReport: report([issue({})], false) }))).toBe(
"blocked",
);
// A failed report never reads as passed, whatever the issue list says.
expect(gateStatus(render({ gateReport: report([], false) }))).toBe("blocked");
});
});
describe("sortedIssues", () => {
it("orders by time, untimed last, blockers first at equal time", () => {
const sorted = sortedIssues([
issue({ code: "untimed" }),
issue({ code: "late", atMs: 5000 }),
issue({ code: "warn", atMs: 1000, severity: "warn" }),
issue({ code: "block", atMs: 1000 }),
]);
expect(sorted.map((item) => item.code)).toEqual(["block", "warn", "late", "untimed"]);
});
});
describe("projectVariants", () => {
it("keeps one card per profile with the newest plan and renders", () => {
const plans = [
plan({ id: "p1", version: 1 }),
plan({ id: "p2", version: 2 }),
plan({ id: "q1", profileId: "tiktok" }),
];
const renders = [
render({ id: "d1", createdAt: "2026-10-01T10:00:00.000Z" }),
render({ id: "d2", createdAt: "2026-10-01T11:00:00.000Z" }),
render({ id: "f1", quality: "final" }),
];
const variants = projectVariants(["youtube-shorts", "instagram-reels"], plans, renders);
expect(variants.map((variant) => variant.profileId)).toEqual([
"youtube-shorts",
"instagram-reels",
"tiktok",
]);
expect(variants[0]?.plan?.id).toBe("p2");
expect(variants[0]?.draft?.id).toBe("d2");
expect(variants[0]?.final?.id).toBe("f1");
expect(variants[1]?.plan).toBeNull();
});
});
describe("reviewedPlan", () => {
it("follows auto-fix versions created by the gate", () => {
const plans = [
plan({ id: "p1", version: 1 }),
plan({ id: "p2", version: 2, createdBy: "agent" }),
plan({ id: "p3", version: 3, createdBy: "agent" }),
];
expect(reviewedPlan(render({ planId: "p1" }), plans)?.id).toBe("p1");
expect(
reviewedPlan(render({ planId: "p1", gateReport: report([], false, 1) }), plans)?.id,
).toBe("p2");
});
});
describe("thumbnailVariant", () => {
it("strips the extension", () => {
expect(thumbnailVariant({ fileName: "thumb-b.png" })).toBe("thumb-b");
});
});

View File

@ -0,0 +1,108 @@
import type {
GateIssue,
MediaEditPlan,
MediaRender,
MediaRenderOutput,
MediaRenderQuality,
MediaRenderStatus,
} from "@rakazo/contracts";
/** Renders still moving; clients poll while any is in one of these states. */
const ACTIVE: ReadonlySet<MediaRenderStatus> = new Set(["queued", "rendering", "gating"]);
/** How often to re-read a render (or project) while work is in progress. */
export const RENDER_POLL_MS = 2000;
export function isRenderActive(render: Pick<MediaRender, "status">): boolean {
return ACTIVE.has(render.status);
}
export type GateStatus = "pass" | "warn" | "blocked";
/** Gate outcome of a finished render, or null while there is no report yet. */
export function gateStatus(render: Pick<MediaRender, "status" | "gateReport">): GateStatus | null {
const report = render.gateReport;
if (render.status === "blocked") return "blocked";
if (!report || isRenderActive(render)) return null;
if (!report.passed || report.issues.some((issue) => issue.severity === "block")) {
return "blocked";
}
return report.issues.length > 0 ? "warn" : "pass";
}
/** Gate issues in timeline order (untimed ones last), blockers before warnings at equal times. */
export function sortedIssues(issues: GateIssue[]): GateIssue[] {
const at = (issue: GateIssue) => issue.atMs ?? Number.POSITIVE_INFINITY;
return [...issues].sort((a, b) => {
if (at(a) !== at(b)) return at(a) < at(b) ? -1 : 1;
if (a.severity !== b.severity) return a.severity === "block" ? -1 : 1;
return a.code.localeCompare(b.code);
});
}
function newest<T extends { createdAt: string }>(items: T[]): T | null {
let best: T | null = null;
for (const item of items) if (!best || item.createdAt > best.createdAt) best = item;
return best;
}
export interface Variant {
profileId: string;
/** Newest plan version for the profile. */
plan: MediaEditPlan | null;
/** Newest render of the profile in each quality. */
draft: MediaRender | null;
final: MediaRender | null;
}
/** One variant per project profile: its newest plan and newest draft and final renders. */
export function projectVariants(
profileIds: string[],
plans: MediaEditPlan[],
renders: MediaRender[],
): Variant[] {
const ids = [...profileIds];
for (const plan of plans) if (!ids.includes(plan.profileId)) ids.push(plan.profileId);
return ids.map((profileId) => {
const own = plans.filter((plan) => plan.profileId === profileId);
let plan: MediaEditPlan | null = null;
for (const candidate of own) if (!plan || candidate.version > plan.version) plan = candidate;
const ofQuality = (quality: MediaRenderQuality) =>
newest(
renders.filter((render) => render.profileId === profileId && render.quality === quality),
);
return { profileId, plan, draft: ofQuality("draft"), final: ofQuality("final") };
});
}
/**
* The plan whose timeline a render's gate report refers to: the started plan,
* or the newest auto-fix version of it when the gate repaired the plan.
*/
export function reviewedPlan(render: MediaRender, plans: MediaEditPlan[]): MediaEditPlan | null {
const started = plans.find((plan) => plan.id === render.planId) ?? null;
const rounds = render.gateReport?.autoFixRound ?? 0;
if (!started || rounds === 0) return started;
let best = started;
for (const plan of plans) {
if (
plan.profileId === started.profileId &&
plan.version > best.version &&
plan.version <= started.version + rounds &&
plan.createdBy === "agent"
) {
best = plan;
}
}
return best;
}
/** True when anything in a project still renders. */
export function anyRenderActive(renders: MediaRender[]): boolean {
return renders.some(isRenderActive);
}
/** Thumbnail variant name from its output file (`thumb-a.png` → `thumb-a`). */
export function thumbnailVariant(output: Pick<MediaRenderOutput, "fileName">): string {
return output.fileName.replace(/\.[a-z0-9]+$/i, "");
}

View File

@ -0,0 +1,162 @@
import { useLingui } from "@lingui/react/macro";
import type { GateIssue } from "@rakazo/contracts";
import { cn } from "@rakazo/ui-web";
import { useRef } from "react";
import type { MaskBox } from "./timeline";
import { clampBox, formatMs } from "./timeline";
/**
* A draggable, resizable mask box over the source frame. Coordinates are
* normalized to the frame; the parent must be the displayed frame itself.
*/
export function MaskBoxEditor({
box,
label,
selected,
onSelect,
onChange,
}: {
box: MaskBox;
label: string;
selected: boolean;
onSelect: () => void;
onChange: (box: MaskBox) => void;
}) {
const gesture = useRef<{
mode: "move" | "resize";
x: number;
y: number;
start: MaskBox;
frame: DOMRect;
} | null>(null);
const begin = (mode: "move" | "resize") => (event: React.PointerEvent<HTMLElement>) => {
event.stopPropagation();
event.preventDefault();
const frame = event.currentTarget.closest("[data-frame]")?.getBoundingClientRect();
if (!frame) return;
onSelect();
event.currentTarget.setPointerCapture(event.pointerId);
gesture.current = { mode, x: event.clientX, y: event.clientY, start: box, frame };
};
const move = (event: React.PointerEvent<HTMLElement>) => {
const active = gesture.current;
if (!active) return;
const dx = (event.clientX - active.x) / active.frame.width;
const dy = (event.clientY - active.y) / active.frame.height;
const { start } = active;
onChange(
clampBox(
active.mode === "move"
? { ...start, x: start.x + dx, y: start.y + dy }
: { ...start, w: start.w + dx, h: start.h + dy },
),
);
};
const end = () => {
gesture.current = null;
};
const nudge = (event: React.KeyboardEvent) => {
const step = event.shiftKey ? 0.05 : 0.01;
const delta: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const [dx, dy] = delta[event.key] ?? [0, 0];
if (dx === 0 && dy === 0) return;
event.preventDefault();
onChange(clampBox({ ...box, x: box.x + dx, y: box.y + dy }));
};
return (
<button
type="button"
aria-label={label}
aria-pressed={selected}
data-testid="media-mask"
onPointerDown={begin("move")}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
onKeyDown={nudge}
className={cn(
"pointer-events-auto absolute cursor-move touch-none rounded-sm border-2 bg-foreground/20 backdrop-blur-sm",
selected ? "border-primary" : "border-background",
)}
style={{
left: `${box.x * 100}%`,
top: `${box.y * 100}%`,
width: `${box.w * 100}%`,
height: `${box.h * 100}%`,
}}
>
<span
aria-hidden
onPointerDown={begin("resize")}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
className="absolute -right-1.5 -bottom-1.5 size-3 cursor-nwse-resize rounded-sm border border-background bg-primary"
/>
</button>
);
}
export interface CoverageRow {
key: string;
label: string;
issues: GateIssue[];
}
/**
* Per person and per mark: a bar over the program length, tinted where the
* gate found the protection incomplete. A clean row means full coverage.
*/
export function CoverageHeatmap({
rows,
durationMs,
onSeek,
}: {
rows: CoverageRow[];
durationMs: number;
onSeek: (ms: number) => void;
}) {
const { t } = useLingui();
if (rows.length === 0 || durationMs <= 0) return null;
const pct = (ms: number) => `${Math.min(100, Math.max(0, (ms / durationMs) * 100))}%`;
return (
<div className="flex flex-col gap-1.5" data-testid="media-coverage">
{rows.map((row) => (
<div key={row.key} className="grid grid-cols-[8rem_1fr] items-center gap-2">
<span className="truncate text-ui-body-sm">{row.label}</span>
<div
className={cn(
"relative h-3 rounded-full",
row.issues.length === 0 ? "bg-success/30" : "bg-muted",
)}
>
{row.issues.map((issue, index) => {
const at = issue.atMs ?? 0;
const end = Math.max(issue.endMs ?? at, at + durationMs * 0.01);
return (
<button
key={`${issue.code}-${at}-${index}`}
type="button"
aria-label={t`${formatMs(at)}: ${issue.message}`}
onClick={() => onSeek(at)}
className={cn(
"absolute inset-y-0 rounded-full",
issue.severity === "block" ? "bg-destructive" : "bg-warning",
)}
style={{ left: pct(at), width: `calc(${pct(end)} - ${pct(at)})` }}
/>
);
})}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,152 @@
import type { Edl } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import {
addRegion,
applyDrag,
clampBox,
edlDurationMs,
errorTarget,
formatMs,
freeId,
locateOutputTime,
moveClip,
regionBoxAt,
setRegionKeyframe,
trimClip,
} from "./timeline";
function edl(): Edl {
return {
edlVersion: 1,
output: {
profileId: "example-short-vertical",
width: 1080,
height: 1920,
fps: 30,
durationTargetMs: 8000,
durationHardMinMs: 1000,
durationHardMaxMs: 60000,
},
tracks: {
video: [
{
id: "a",
assetId: "asset-1",
inMs: 1000,
outMs: 4000,
atMs: 0,
speed: 1,
reframe: "auto",
stabilize: false,
muted: false,
},
{
id: "b",
assetId: "asset-2",
inMs: 0,
outMs: 4000,
atMs: 3000,
speed: 2,
reframe: "none",
stabilize: false,
muted: false,
},
],
audio: [],
},
transitions: [],
overlays: [{ id: "text-1", kind: "text", atMs: 500, durationMs: 1000, text: "Hi" }],
intro: { templateId: "intro-minimal", params: {}, durationMs: 1500 },
redactions: { faces: [], marks: [], regions: [] },
metadata: { tags: [], aiAltered: false, aiAlterations: [] },
};
}
describe("timeline math", () => {
it("counts intro, clips at their speed, and outro", () => {
// intro 1500 + clip b ends at 3000 + 4000/2
expect(edlDurationMs(edl())).toBe(6500);
});
it("maps output time to the clip and source time, intro to nothing", () => {
expect(locateOutputTime(edl(), 1000)).toBeNull();
const first = locateOutputTime(edl(), 1500 + 500);
expect(first?.clip.id).toBe("a");
expect(first?.sourceMs).toBe(1500);
const second = locateOutputTime(edl(), 1500 + 4000);
expect(second?.clip.id).toBe("b");
expect(second?.sourceMs).toBe(2000);
});
it("moves clips but never before zero", () => {
expect(moveClip(edl(), "b", 500).tracks.video[1]?.atMs).toBe(3500);
expect(moveClip(edl(), "a", -900).tracks.video[0]?.atMs).toBe(0);
});
it("trims the end inside the asset and keeps a minimum length", () => {
expect(trimClip(edl(), "a", "end", 5000, 4500).tracks.video[0]?.outMs).toBe(4500);
expect(trimClip(edl(), "a", "end", -10_000, 4500).tracks.video[0]?.outMs).toBe(1100);
});
it("trims the start without moving the clip end on the timeline", () => {
const trimmed = trimClip(edl(), "b", "start", 500, 4000).tracks.video[1];
// 500 ms of output at speed 2 is 1000 ms of source.
expect(trimmed?.inMs).toBe(1000);
expect(trimmed?.atMs).toBe(3500);
// Cannot pull the start before the asset begins.
const extended = trimClip(edl(), "a", "start", -5000, 4000).tracks.video[0];
expect(extended?.inMs).toBe(1000);
expect(extended?.atMs).toBe(0);
});
it("applies drags by handle id", () => {
const durations = new Map([
["asset-1", 4500],
["asset-2", 4000],
]);
expect(applyDrag(edl(), "trim-end:a", 300, durations).tracks.video[0]?.outMs).toBe(4300);
expect(applyDrag(edl(), "overlay:text-1", 250, durations).overlays[0]?.atMs).toBe(750);
expect(applyDrag(edl(), "clip:a", 100, durations).tracks.video[0]?.atMs).toBe(100);
});
it("points EDL errors at the clip or overlay they name", () => {
expect(errorTarget(edl(), { path: "tracks.video[1].outMs" })).toEqual({
kind: "clip",
id: "b",
});
expect(errorTarget(edl(), { path: "overlays[0].box" })).toEqual({
kind: "overlay",
id: "text-1",
});
expect(errorTarget(edl(), { path: "output" })).toBeNull();
expect(errorTarget(edl(), { path: "tracks.video[9]" })).toBeNull();
});
it("formats timestamps", () => {
expect(formatMs(0)).toBe("0:00.0");
expect(formatMs(65_300)).toBe("1:05.3");
});
it("finds free ids", () => {
expect(freeId(["mask-1", "mask-2"], "mask")).toBe("mask-3");
});
});
describe("masks", () => {
it("adds a region and keyframes it in source time", () => {
const added = addRegion(edl(), "a", 2000);
expect(added.id).toBe("mask-1");
const moved = setRegionKeyframe(added.edl, added.id, 3000, { x: 0.9, y: 0.1, w: 0.3, h: 0.2 });
const region = moved.redactions.regions[0]!;
expect(region.keyframes.map((keyframe) => keyframe.tMs)).toEqual([2000, 3000]);
// Clamped into the frame.
expect(region.keyframes[1]?.box.x).toBeCloseTo(0.7);
expect(regionBoxAt(region, 2500)).toEqual(region.keyframes[0]?.box);
expect(regionBoxAt(region, 3500)).toEqual(region.keyframes[1]?.box);
expect(regionBoxAt(region, 0)).toEqual(region.keyframes[0]?.box);
});
it("clamps boxes to a minimum size inside the frame", () => {
expect(clampBox({ x: -1, y: 2, w: 0, h: 5 })).toEqual({ x: 0, y: 0, w: 0.02, h: 1 });
});
});

View File

@ -0,0 +1,271 @@
import type { Edl, EdlError, EdlOverlay, EdlVideoClip } from "@rakazo/contracts";
/*
* Timeline math for the editor and review player. Mirrors the timing helpers
* in `@rakazo/media` (`clipLengthMs`, `edlDurationMs`, `locateOutputTime`),
* which the web app cannot import yet; the server validates every plan anyway.
*/
/** Shortest clip the editor lets a trim produce. */
export const MIN_CLIP_MS = 100;
export function clipLengthMs(clip: Pick<EdlVideoClip, "inMs" | "outMs" | "speed">): number {
return Math.max(0, Math.round((clip.outMs - clip.inMs) / clip.speed));
}
export function introMs(edl: Edl): number {
return edl.intro?.durationMs ?? 0;
}
export function mainProgramMs(edl: Edl): number {
let end = 0;
for (const clip of edl.tracks.video) end = Math.max(end, clip.atMs + clipLengthMs(clip));
return end;
}
export function edlDurationMs(edl: Edl): number {
return introMs(edl) + mainProgramMs(edl) + (edl.outro?.durationMs ?? 0);
}
/** The clip and source time an output timestamp shows; the incoming clip wins during a blend. */
export function locateOutputTime(
edl: Edl,
outputMs: number,
): { clip: EdlVideoClip; sourceMs: number } | null {
const t = outputMs - introMs(edl);
if (t < 0) return null;
const visible = edl.tracks.video
.filter((clip) => t >= clip.atMs && t < clip.atMs + clipLengthMs(clip))
.sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id));
const clip = visible.at(-1);
if (!clip) return null;
return {
clip,
sourceMs: Math.min(clip.outMs, Math.round(clip.inMs + (t - clip.atMs) * clip.speed)),
};
}
/** Output timestamp where a clip starts (intro included). */
export function clipOutputStartMs(edl: Edl, clip: EdlVideoClip): number {
return introMs(edl) + clip.atMs;
}
function replaceClip(edl: Edl, clipId: string, next: (clip: EdlVideoClip) => EdlVideoClip): Edl {
return {
...edl,
tracks: {
...edl.tracks,
video: edl.tracks.video.map((clip) => (clip.id === clipId ? next(clip) : clip)),
},
};
}
/** Move a clip on the output timeline. */
export function moveClip(edl: Edl, clipId: string, deltaMs: number): Edl {
return replaceClip(edl, clipId, (clip) => ({
...clip,
atMs: Math.max(0, Math.round(clip.atMs + deltaMs)),
}));
}
/**
* Trim a clip edge by an output-time delta. The start edge keeps the clip's
* end in place on the timeline; both edges stay inside the source asset.
*/
export function trimClip(
edl: Edl,
clipId: string,
edge: "start" | "end",
deltaMs: number,
assetDurationMs: number | null,
): Edl {
return replaceClip(edl, clipId, (clip) => {
const sourceDelta = Math.round(deltaMs * clip.speed);
const minSource = Math.round(MIN_CLIP_MS * clip.speed);
if (edge === "end") {
const max = assetDurationMs ?? Number.POSITIVE_INFINITY;
const outMs = Math.min(max, Math.max(clip.inMs + minSource, clip.outMs + sourceDelta));
return { ...clip, outMs };
}
// Never start before the asset, the timeline, or past the end edge.
const lowest = Math.max(0, clip.inMs - Math.round(clip.atMs * clip.speed));
const inMs = Math.min(clip.outMs - minSource, Math.max(lowest, clip.inMs + sourceDelta));
const atMs = Math.max(0, clip.atMs + Math.round((inMs - clip.inMs) / clip.speed));
return { ...clip, inMs, atMs };
});
}
/** Move an overlay on the output timeline. */
export function moveOverlay(edl: Edl, overlayId: string, deltaMs: number): Edl {
return {
...edl,
overlays: edl.overlays.map((overlay) =>
overlay.id === overlayId
? { ...overlay, atMs: Math.max(0, Math.round(overlay.atMs + deltaMs)) }
: overlay,
),
};
}
export function updateOverlay(edl: Edl, overlayId: string, patch: Partial<EdlOverlay>): Edl {
return {
...edl,
overlays: edl.overlays.map((overlay) =>
overlay.id === overlayId ? { ...overlay, ...patch } : overlay,
),
};
}
/** A fresh `prefix-n` id in `[a-z0-9_-]` that `taken` does not contain. */
export function freeId(taken: Iterable<string>, prefix: string): string {
const used = new Set(taken);
for (let index = 1; ; index += 1) {
const id = `${prefix}-${index}`;
if (!used.has(id)) return id;
}
}
/** A fresh id that no clip, overlay, or redaction of the plan uses yet. */
export function nextId(edl: Edl, prefix: string): string {
return freeId(
[
...edl.tracks.video.map((clip) => clip.id),
...edl.tracks.audio.map((clip) => clip.id),
...edl.overlays.map((overlay) => overlay.id),
...edl.redactions.faces.map((face) => face.id),
...edl.redactions.regions.map((region) => region.id),
],
prefix,
);
}
/**
* Which timeline item an EDL error points at, from its machine-readable path
* (`tracks.video[2].outMs`, `overlays[0].box`). Null for plan-wide errors.
*/
export function errorTarget(
edl: Edl,
error: Pick<EdlError, "path">,
): { kind: "clip" | "overlay"; id: string } | null {
const clip = /^tracks\.video\[(\d+)\]/.exec(error.path);
if (clip) {
const id = edl.tracks.video[Number(clip[1])]?.id;
return id ? { kind: "clip", id } : null;
}
const overlay = /^overlays\[(\d+)\]/.exec(error.path);
if (overlay) {
const id = edl.overlays[Number(overlay[1])]?.id;
return id ? { kind: "overlay", id } : null;
}
return null;
}
/** Masks drawn in review: region redactions keyed to a clip, keyframes in source time. */
export type RegionRedaction = Edl["redactions"]["regions"][number];
export type MaskBox = RegionRedaction["keyframes"][number]["box"];
/** The box a region shows at a source time: the last keyframe at or before it, else the first. */
export function regionBoxAt(region: RegionRedaction, sourceMs: number): MaskBox {
const sorted = [...region.keyframes].sort((a, b) => a.tMs - b.tMs);
let box = sorted[0]?.box ?? { x: 0, y: 0, w: 0, h: 0 };
for (const keyframe of sorted) if (keyframe.tMs <= sourceMs) box = keyframe.box;
return box;
}
/** Clamp a box into the frame with a minimum size. */
export function clampBox(box: MaskBox): MaskBox {
const w = Math.min(1, Math.max(0.02, box.w));
const h = Math.min(1, Math.max(0.02, box.h));
return {
x: Math.min(1 - w, Math.max(0, box.x)),
y: Math.min(1 - h, Math.max(0, box.y)),
w,
h,
};
}
/** Add a mask to a clip with one keyframe at `sourceMs`. Returns the plan and the new id. */
export function addRegion(edl: Edl, clipId: string, sourceMs: number): { edl: Edl; id: string } {
const id = nextId(edl, "mask");
const region: RegionRedaction = {
id,
clipId,
treatment: "blur",
keyframes: [{ tMs: Math.round(sourceMs), box: { x: 0.4, y: 0.35, w: 0.2, h: 0.3 } }],
};
return {
edl: {
...edl,
redactions: { ...edl.redactions, regions: [...edl.redactions.regions, region] },
},
id,
};
}
/** Set a region's box at `sourceMs`, replacing a keyframe at that exact time or adding one. */
export function setRegionKeyframe(edl: Edl, regionId: string, sourceMs: number, box: MaskBox): Edl {
const tMs = Math.round(sourceMs);
const clamped = clampBox(box);
return {
...edl,
redactions: {
...edl.redactions,
regions: edl.redactions.regions.map((region) => {
if (region.id !== regionId) return region;
const others = region.keyframes.filter((keyframe) => keyframe.tMs !== tMs);
const keyframes = [...others, { tMs, box: clamped }].sort((a, b) => a.tMs - b.tMs);
return { ...region, keyframes };
}),
},
};
}
export function removeRegion(edl: Edl, regionId: string): Edl {
return {
...edl,
redactions: {
...edl.redactions,
regions: edl.redactions.regions.filter((region) => region.id !== regionId),
},
};
}
export type DragId =
| `clip:${string}`
| `trim-start:${string}`
| `trim-end:${string}`
| `overlay:${string}`;
/** Apply an in-flight drag (pixels converted to ms) to the plan. */
export function applyDrag(
edl: Edl,
id: DragId,
deltaMs: number,
assetDurations: ReadonlyMap<string, number | null>,
): Edl {
const [kind, target] = id.split(/:(.*)/s, 2) as [string, string];
const assetDuration = (clipId: string) => {
const clip = edl.tracks.video.find((candidate) => candidate.id === clipId);
return clip ? (assetDurations.get(clip.assetId) ?? null) : null;
};
switch (kind) {
case "clip":
return moveClip(edl, target, deltaMs);
case "trim-start":
return trimClip(edl, target, "start", deltaMs, assetDuration(target));
case "trim-end":
return trimClip(edl, target, "end", deltaMs, assetDuration(target));
case "overlay":
return moveOverlay(edl, target, deltaMs);
default:
return edl;
}
}
/** `1:05.3` style timestamp. */
export function formatMs(ms: number): string {
const tenths = Math.max(0, Math.round(ms / 100));
const minutes = Math.floor(tenths / 600);
const seconds = (tenths % 600) / 10;
return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
}

View File

@ -0,0 +1,587 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type {
BrandKit,
BrandKitRecord,
CaptionStylePreset,
IntroVariant,
MotionTemplateInfo,
Watermark,
} from "@rakazo/contracts";
import { Button, Card, CardContent, Field, FieldLabel, Input, Skeleton } from "@rakazo/ui-web";
import { Check, Plus, Save, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useId, useState } from "react";
import { LoadingState, SuccessPop } from "../../components/ai/primitives";
import { ErrorLine, errorMessage, ViewHeader } from "../../components/media/editor/chrome";
import {
NumberField,
SelectField,
TextField,
ToggleField,
} from "../../components/media/editor/fields";
import { mediaArtifactUrl, useMediaObjectUrl } from "../../components/media/editor/media-files";
import { formatMs, freeId } from "../../components/media/editor/timeline";
import { rpc } from "../../lib/rpc";
import type { MediaViewProps } from "./nav";
/** Starting point for a new kit. Brand colors are render data, not UI colors. */
function emptyKit(name: string): BrandKit {
return {
name,
colors: {
primary: "#111111",
secondary: "#555555",
accent: "#999999",
text: "#ffffff",
background: "#000000",
},
fonts: [],
logoArtifactIds: [],
captionPresets: [],
musicBeds: [],
};
}
/** Brand kits: a list without an id, the kit editor and intro generator with one. */
export function BrandKitView({ navigate, brandKitId }: MediaViewProps & { brandKitId?: string }) {
const { t } = useLingui();
const [records, setRecords] = useState<BrandKitRecord[] | null>(null);
const [templates, setTemplates] = useState<MotionTemplateInfo[]>([]);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setRecords(await rpc.media.brandKits.list({}));
} catch (cause) {
setError(errorMessage(cause));
}
}, []);
useEffect(() => {
void load();
void rpc.media.brandKits
.templates({})
.then(setTemplates)
.catch(() => undefined);
}, [load]);
if (!records) {
return (
<div className="flex flex-col gap-4 p-4">
<ErrorLine error={error} />
{error ? null : <Skeleton className="h-48 w-full" />}
</div>
);
}
const record = brandKitId ? records.find((candidate) => candidate.id === brandKitId) : undefined;
if (brandKitId && record) {
return (
<KitEditor
key={record.id}
record={record}
templates={templates}
onBack={() => navigate({ name: "brandKit" })}
onSaved={(saved) => setRecords((current) => upsertRecord(current ?? [], saved))}
/>
);
}
if (brandKitId === "new" || records.length === 0) {
return (
<KitEditor
record={null}
templates={templates}
onBack={() => navigate({ name: "library" })}
onSaved={(saved) => {
setRecords((current) => upsertRecord(current ?? [], saved));
navigate({ name: "brandKit", brandKitId: saved.id });
}}
/>
);
}
return (
<div className="flex flex-col gap-4 p-4" data-testid="media-brand-kits">
<ViewHeader
title={t`Brand kits`}
backLabel={t`Back to library`}
onBack={() => navigate({ name: "library" })}
>
<Button size="sm" onClick={() => navigate({ name: "brandKit", brandKitId: "new" })}>
<Plus />
<Trans>New</Trans>
</Button>
</ViewHeader>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(14rem,1fr))]">
{records.map((item) => (
<button
key={item.id}
type="button"
className="text-left"
onClick={() => navigate({ name: "brandKit", brandKitId: item.id })}
>
<Card className="transition-colors hover:bg-muted/50">
<CardContent className="flex items-center gap-3">
<Swatches colors={item.kit.colors} />
<span className="min-w-0 flex-1 truncate text-ui-body font-medium">
{item.kit.name}
</span>
</CardContent>
</Card>
</button>
))}
</div>
</div>
);
}
function upsertRecord(records: BrandKitRecord[], saved: BrandKitRecord): BrandKitRecord[] {
return records.some((item) => item.id === saved.id)
? records.map((item) => (item.id === saved.id ? saved : item))
: [...records, saved];
}
function Swatches({ colors }: { colors: BrandKit["colors"] }) {
return (
<span aria-hidden className="flex -space-x-1">
{[colors.primary, colors.secondary, colors.accent].map((color, index) => (
<span
key={index}
className="size-5 rounded-full border border-border"
style={{ backgroundColor: color }}
/>
))}
</span>
);
}
function KitEditor({
record,
templates,
onBack,
onSaved,
}: {
record: BrandKitRecord | null;
templates: MotionTemplateInfo[];
onBack: () => void;
onSaved: (record: BrandKitRecord) => void;
}) {
const { t } = useLingui();
const [kit, setKit] = useState<BrandKit>(() => record?.kit ?? emptyKit(t`Brand kit`));
const [dirty, setDirty] = useState(record === null);
const [saving, setSaving] = useState(false);
const [warnings, setWarnings] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const patch = (next: Partial<BrandKit>) => {
setKit((current) => ({ ...current, ...next }));
setDirty(true);
};
async function save(next: BrandKit = kit) {
setSaving(true);
setError(null);
try {
const result = await rpc.media.brandKits.upsert({
...(record ? { id: record.id } : {}),
kit: next,
});
setKit(result.record.kit);
setWarnings(result.warnings);
setDirty(false);
onSaved(result.record);
} catch (cause) {
setError(errorMessage(cause));
} finally {
setSaving(false);
}
}
const colorLabels: Record<keyof BrandKit["colors"], string> = {
primary: t`Primary`,
secondary: t`Secondary`,
accent: t`Accent`,
text: t`Text`,
background: t`Background`,
};
return (
<div className="flex flex-col gap-6 p-4" data-testid="media-brand-kit">
<ViewHeader title={kit.name} backLabel={t`Back`} onBack={onBack}>
<Button size="sm" disabled={!dirty || saving} onClick={() => void save()}>
{saving ? (
<LoadingState label={t`Saving`} />
) : (
<>
<Save />
<Trans>Save</Trans>
</>
)}
</Button>
</ViewHeader>
<ErrorLine error={error} />
{warnings.map((warning) => (
<p key={warning} className="text-ui-body-sm text-warning">
{warning}
</p>
))}
<section className="grid max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
<TextField
label={t`Name`}
value={kit.name}
maxLength={60}
onChange={(name) => patch({ name })}
/>
<TextField
label={t`Tone`}
value={kit.tone ?? ""}
maxLength={200}
onChange={(tone) => patch({ tone: tone || undefined })}
/>
<TextField
label={t`Audience`}
value={kit.audience ?? ""}
maxLength={200}
onChange={(audience) => patch({ audience: audience || undefined })}
/>
</section>
<section className="flex flex-wrap gap-4">
{(Object.keys(colorLabels) as Array<keyof BrandKit["colors"]>).map((key) => (
<ColorField
key={key}
label={colorLabels[key]}
value={kit.colors[key]}
onChange={(value) => patch({ colors: { ...kit.colors, [key]: value } })}
/>
))}
</section>
<WatermarkFields watermark={kit.watermark} onChange={(watermark) => patch({ watermark })} />
<CaptionPresets
presets={kit.captionPresets}
colors={kit.colors}
onChange={(captionPresets) => patch({ captionPresets })}
/>
{kit.fonts.length + kit.musicBeds.length > 0 ? (
<ul className="flex flex-col gap-1 text-ui-body-sm text-muted-foreground">
{kit.fonts.map((font) => (
<li key={font.artifactId}>
{font.family}
{font.license ? ` · ${font.license}` : ""}
</li>
))}
{kit.musicBeds.map((bed) => (
<li key={bed.artifactId}>
{bed.title} · {bed.license}
</li>
))}
</ul>
) : null}
{record ? (
<IntroGenerator
kitId={record.id}
kit={kit}
templates={templates}
disabled={dirty || saving}
onUse={(variant) => void save({ ...kit, intro: variant.template })}
/>
) : null}
</div>
);
}
function ColorField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
const id = useId();
return (
<Field className="w-24 gap-1">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
id={id}
type="color"
className="h-control-md p-1"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</Field>
);
}
function WatermarkFields({
watermark,
onChange,
}: {
watermark: Watermark | undefined;
onChange: (watermark: Watermark | undefined) => void;
}) {
const { t } = useLingui();
return (
<section className="flex max-w-2xl flex-col gap-3">
<ToggleField
label={t`Watermark`}
checked={watermark !== undefined}
onChange={(on) =>
onChange(on ? { position: "bottom_right", opacity: 0.6, scale: 0.12 } : undefined)
}
/>
{watermark ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SelectField
label={t`Position`}
value={watermark.position}
options={[
{ value: "top_left", label: t`Top left` },
{ value: "top_right", label: t`Top right` },
{ value: "bottom_left", label: t`Bottom left` },
{ value: "bottom_right", label: t`Bottom right` },
{ value: "center", label: t`Center` },
]}
onChange={(position) => onChange({ ...watermark, position })}
/>
<NumberField
label={t`Opacity`}
value={watermark.opacity}
min={0}
max={1}
step={0.05}
onChange={(opacity) => onChange({ ...watermark, opacity })}
/>
<NumberField
label={t`Size`}
value={watermark.scale}
min={0.02}
max={0.5}
step={0.01}
onChange={(scale) => onChange({ ...watermark, scale })}
/>
</div>
) : null}
</section>
);
}
function CaptionPresets({
presets,
colors,
onChange,
}: {
presets: CaptionStylePreset[];
colors: BrandKit["colors"];
onChange: (presets: CaptionStylePreset[]) => void;
}) {
const { t } = useLingui();
const update = (id: string, next: Partial<CaptionStylePreset>) =>
onChange(presets.map((preset) => (preset.id === id ? { ...preset, ...next } : preset)));
return (
<section className="flex max-w-2xl flex-col gap-3" data-testid="media-caption-presets">
{presets.map((preset) => (
<div key={preset.id} className="grid grid-cols-2 items-end gap-3 sm:grid-cols-5">
<TextField
label={t`Caption style`}
value={preset.name}
maxLength={40}
onChange={(name) => update(preset.id, { name })}
/>
<SelectField
label={t`Look`}
value={preset.template}
options={[
{ value: "clean", label: t`Clean` },
{ value: "karaoke", label: t`Karaoke` },
{ value: "bold_social", label: t`Bold` },
]}
onChange={(template) => update(preset.id, { template })}
/>
<TextField
label={t`Font`}
value={preset.fontFamily}
maxLength={80}
onChange={(fontFamily) => update(preset.id, { fontFamily })}
/>
<ColorField
label={t`Color`}
value={preset.textColor}
onChange={(textColor) => update(preset.id, { textColor })}
/>
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Remove ${preset.name}`}
onClick={() => onChange(presets.filter((item) => item.id !== preset.id))}
>
<Trash2 />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="self-start"
disabled={presets.length >= 12}
onClick={() =>
onChange([
...presets,
{
id: freeId(
presets.map((preset) => preset.id),
"caption",
),
name: t`Captions`,
template: "clean",
fontFamily: "Inter",
sizePct: 5,
textColor: colors.text,
highlightColor: colors.accent,
uppercase: false,
},
])
}
>
<Plus />
<Trans>Caption style</Trans>
</Button>
</section>
);
}
function IntroGenerator({
kitId,
kit,
templates,
disabled,
onUse,
}: {
kitId: string;
kit: BrandKit;
templates: MotionTemplateInfo[];
disabled: boolean;
onUse: (variant: IntroVariant) => void;
}) {
const { t } = useLingui();
const [variants, setVariants] = useState<IntroVariant[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const templateName = (id: string) => templates.find((item) => item.id === id)?.name ?? id;
async function suggest() {
setBusy(true);
setError(null);
try {
setVariants(await rpc.media.brandKits.introVariants({ id: kitId, count: 3 }));
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(false);
}
}
const inUse = (variant: IntroVariant) =>
kit.intro?.templateId === variant.template.templateId &&
kit.intro.durationMs === variant.template.durationMs &&
JSON.stringify(kit.intro.params) === JSON.stringify(variant.template.params);
return (
<section className="flex flex-col gap-3" data-testid="media-intro-generator">
<div className="flex items-center gap-3">
<h3 className="text-ui-body font-semibold">
<Trans>Intro</Trans>
</h3>
{kit.intro ? (
<span className="text-ui-body-sm text-muted-foreground">
{templateName(kit.intro.templateId)} · {formatMs(kit.intro.durationMs)}
</span>
) : null}
<Button variant="outline" size="sm" disabled={busy || disabled} onClick={suggest}>
{busy ? (
<LoadingState label={t`Rendering previews`} />
) : (
<>
<Sparkles />
{variants.length > 0 ? <Trans>Suggest again</Trans> : <Trans>Suggest</Trans>}
</>
)}
</Button>
</div>
<ErrorLine error={error} />
{variants.length > 0 ? (
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(14rem,1fr))]">
{variants.map((variant) => (
<IntroCard
key={variant.id}
variant={variant}
name={templateName(variant.template.templateId)}
selected={inUse(variant)}
disabled={disabled}
onUse={() => onUse(variant)}
/>
))}
</div>
) : null}
</section>
);
}
function IntroCard({
variant,
name,
selected,
disabled,
onUse,
}: {
variant: IntroVariant;
name: string;
selected: boolean;
disabled: boolean;
onUse: () => void;
}) {
const { t } = useLingui();
const preview = useMediaObjectUrl(
variant.previewArtifactId ? mediaArtifactUrl(variant.previewArtifactId) : null,
);
return (
<Card data-testid="media-intro-variant">
<CardContent className="flex flex-col gap-2">
{preview ? (
<video
aria-label={t`${name} preview`}
className="aspect-video w-full rounded-md bg-muted object-contain"
src={preview}
autoPlay
loop
muted
playsInline
/>
) : (
<div className="aspect-video w-full rounded-md bg-muted" />
)}
<div className="flex items-baseline gap-2">
<span className="min-w-0 flex-1 truncate text-ui-body font-medium">{name}</span>
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">
{formatMs(variant.template.durationMs)}
</span>
</div>
<p className="text-ui-body-sm text-muted-foreground">{variant.rationale}</p>
{selected ? (
<SuccessPop label={t`In use`} />
) : (
<Button size="sm" variant="outline" disabled={disabled} onClick={onUse}>
<Check />
<Trans>Use</Trans>
</Button>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,6 @@
import type { MediaViewProps } from "./nav";
/** Placeholder; the W3-web-library slice replaces this view. */
export function CollectionView(_props: MediaViewProps & Record<string, unknown>) {
return null;
}

View File

@ -0,0 +1,450 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type {
CaptionStylePreset,
Edl,
EdlCaptions,
EdlError,
EdlOverlay,
MediaEditPlan,
} from "@rakazo/contracts";
import { Button, Skeleton } from "@rakazo/ui-web";
import { Plus, Save, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LoadingState } from "../../components/ai/primitives";
import { ErrorLine, errorMessage, ViewHeader } from "../../components/media/editor/chrome";
import {
NumberField,
SecondsField,
SelectField,
TextField,
ToggleField,
} from "../../components/media/editor/fields";
import type { PlayerHandle } from "../../components/media/editor/Player";
import { SourcePlayer } from "../../components/media/editor/Player";
import type { TimelineSelection } from "../../components/media/editor/Timeline";
import { Timeline } from "../../components/media/editor/Timeline";
import {
errorTarget,
formatMs,
mainProgramMs,
nextId,
updateOverlay,
} from "../../components/media/editor/timeline";
import { rpc } from "../../lib/rpc";
import type { MediaViewProps } from "./nav";
interface Loaded {
plan: MediaEditPlan;
assetDurations: Map<string, number | null>;
captionPresets: CaptionStylePreset[];
}
/** Timeline editor for one plan version. Saving submits a new version. */
export function EditorView({
navigate,
projectId,
planId,
}: MediaViewProps & { projectId: string; planId: string }) {
const { t } = useLingui();
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [edl, setEdl] = useState<Edl | null>(null);
const [dirty, setDirty] = useState(false);
const [selection, setSelection] = useState<TimelineSelection>(null);
const [errors, setErrors] = useState<EdlError[]>([]);
const [playheadMs, setPlayheadMs] = useState(0);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const player = useRef<PlayerHandle>(null);
useEffect(() => {
let live = true;
setLoaded(null);
setEdl(null);
setDirty(false);
setErrors([]);
setSelection(null);
void (async () => {
try {
const { project, plans } = await rpc.media.projects.get({ projectId });
const plan = plans.find((candidate) => candidate.id === planId);
if (!plan) throw new Error(t`This plan no longer exists.`);
const assetIds = [...new Set(plan.edl.tracks.video.map((clip) => clip.assetId))];
const [assets, kits] = await Promise.all([
Promise.all(
assetIds.map((assetId) =>
rpc.media.assets
.get({ assetId })
.then(({ asset }) => [assetId, asset.durationMs] as const)
.catch(() => [assetId, null] as const),
),
),
project.brandKitId ? rpc.media.brandKits.list({}) : Promise.resolve([]),
]);
if (!live) return;
const kit = kits.find((record) => record.id === project.brandKitId);
setLoaded({
plan,
assetDurations: new Map(assets),
captionPresets: kit?.kit.captionPresets ?? [],
});
setEdl(plan.edl);
} catch (cause) {
if (live) setLoadError(errorMessage(cause));
}
})();
return () => {
live = false;
};
}, [projectId, planId]);
const change = useCallback((next: Edl) => {
setEdl(next);
setDirty(true);
}, []);
const invalid = useMemo(() => {
const ids = new Set<string>();
if (!edl) return ids;
for (const error of errors) {
const target = errorTarget(edl, error);
if (target) ids.add(target.id);
}
return ids;
}, [edl, errors]);
if (!loaded || !edl) {
return (
<div className="flex flex-col gap-4 p-4">
<ErrorLine error={loadError} />
{loadError ? null : <Skeleton className="h-64 w-full" />}
</div>
);
}
async function save() {
if (!edl) return;
setSaving(true);
try {
const result = await rpc.media.plans.submit({ projectId, edl, rationale: null });
if (result.ok) {
setErrors([]);
setDirty(false);
navigate({ name: "editor", projectId, planId: result.plan.id });
} else {
setErrors(result.errors);
}
} catch (cause) {
setErrors([{ code: "schema", path: "", message: errorMessage(cause) }]);
} finally {
setSaving(false);
}
}
function addOverlay() {
if (!edl) return;
const id = nextId(edl, "text");
const atMs = Math.max(0, Math.min(playheadMs, mainProgramMs(edl)));
const overlay: EdlOverlay = { id, kind: "text", atMs, durationMs: 2000, text: "" };
change({ ...edl, overlays: [...edl.overlays, overlay] });
setSelection({ kind: "overlay", id });
}
return (
<div className="flex flex-col gap-4 p-4" data-testid="media-editor">
<ViewHeader
title={`${loaded.plan.profileId} · v${loaded.plan.version}`}
backLabel={t`Back to project`}
onBack={() => navigate({ name: "project", projectId })}
>
<Button size="sm" disabled={!dirty || saving} onClick={save}>
{saving ? (
<LoadingState label={t`Saving`} />
) : (
<>
<Save />
<Trans>Save</Trans>
</>
)}
</Button>
</ViewHeader>
<div className="mx-auto w-full max-w-2xl">
<SourcePlayer
edl={edl}
handleRef={player}
label={t`Plan preview`}
onFrame={(frame) => {
if (frame) setPlayheadMs(frame.outputMs);
}}
/>
</div>
<div className="flex items-center gap-2">
<span className="flex-1 font-mono text-ui-body-sm tabular-nums text-muted-foreground">
{formatMs(playheadMs)}
</span>
<Button variant="outline" size="sm" onClick={addOverlay}>
<Plus />
<Trans>Text</Trans>
</Button>
</div>
<Timeline
edl={edl}
assetDurations={loaded.assetDurations}
selection={selection}
invalid={invalid}
playheadMs={playheadMs}
onSelect={setSelection}
onSeek={(ms) => {
setPlayheadMs(ms);
player.current?.seek(ms);
}}
onChange={change}
/>
{errors.length > 0 ? (
<ul role="alert" className="flex flex-col gap-1" data-testid="media-edl-errors">
{errors.map((error) => {
const target = errorTarget(edl, error);
return (
<li key={`${error.code}-${error.path}-${error.message}`}>
<button
type="button"
disabled={!target}
onClick={() => target && setSelection(target)}
className="text-left text-ui-body-sm text-destructive enabled:hover:underline"
>
{error.message}
</button>
</li>
);
})}
</ul>
) : null}
<Inspector
edl={edl}
selection={selection}
assetDurations={loaded.assetDurations}
captionPresets={loaded.captionPresets}
onChange={change}
onClear={() => setSelection(null)}
/>
</div>
);
}
function Inspector({
edl,
selection,
assetDurations,
captionPresets,
onChange,
onClear,
}: {
edl: Edl;
selection: TimelineSelection;
assetDurations: ReadonlyMap<string, number | null>;
captionPresets: CaptionStylePreset[];
onChange: (edl: Edl) => void;
onClear: () => void;
}) {
const { t } = useLingui();
if (!selection) return null;
if (selection.kind === "clip") {
const clip = edl.tracks.video.find((candidate) => candidate.id === selection.id);
if (!clip) return null;
const max = assetDurations.get(clip.assetId) ?? Number.POSITIVE_INFINITY;
const patch = (next: Partial<typeof clip>) =>
onChange({
...edl,
tracks: {
...edl.tracks,
video: edl.tracks.video.map((candidate) =>
candidate.id === clip.id ? { ...candidate, ...next } : candidate,
),
},
});
return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="media-inspector">
<SecondsField label={t`Start`} ms={clip.atMs} onChange={(atMs) => patch({ atMs })} />
<SecondsField
label={t`In`}
ms={clip.inMs}
onChange={(inMs) => patch({ inMs: Math.min(inMs, clip.outMs - 100) })}
/>
<SecondsField
label={t`Out`}
ms={clip.outMs}
onChange={(outMs) => patch({ outMs: Math.min(max, Math.max(outMs, clip.inMs + 100)) })}
/>
<NumberField
label={t`Speed`}
value={clip.speed}
min={0.25}
max={4}
step={0.25}
onChange={(speed) => patch({ speed })}
/>
<SelectField
label={t`Framing`}
value={Array.isArray(clip.reframe) ? "keyframes" : clip.reframe}
options={[
{ value: "auto", label: t`Follow subject` },
{ value: "none", label: t`Center` },
...(Array.isArray(clip.reframe)
? [{ value: "keyframes" as const, label: t`Keyframes` }]
: []),
]}
onChange={(value) => {
if (value !== "keyframes") patch({ reframe: value });
}}
/>
<ToggleField label={t`Mute`} checked={clip.muted} onChange={(muted) => patch({ muted })} />
{edl.tracks.video.length > 1 ? (
<Button
variant="ghost"
size="sm"
className="self-end"
onClick={() => {
onChange({
...edl,
tracks: {
...edl.tracks,
video: edl.tracks.video.filter((candidate) => candidate.id !== clip.id),
},
transitions: edl.transitions.filter(
(transition) =>
transition.fromClipId !== clip.id && transition.toClipId !== clip.id,
),
redactions: {
...edl.redactions,
regions: edl.redactions.regions.filter((region) => region.clipId !== clip.id),
},
});
onClear();
}}
>
<Trash2 />
<Trans>Remove</Trans>
</Button>
) : null}
</section>
);
}
if (selection.kind === "overlay") {
const overlay = edl.overlays.find((candidate) => candidate.id === selection.id);
if (!overlay) return null;
const patch = (next: Partial<EdlOverlay>) => onChange(updateOverlay(edl, overlay.id, next));
return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="media-inspector">
<SelectField
label={t`Kind`}
value={overlay.kind}
options={[
{ value: "text", label: t`Text` },
{ value: "lower_third", label: t`Lower third` },
{ value: "logo", label: t`Logo` },
{ value: "watermark", label: t`Watermark` },
{ value: "cta_end_card", label: t`End card` },
{ value: "progress_bar", label: t`Progress bar` },
...(overlay.kind === "motion" ? [{ value: "motion" as const, label: t`Motion` }] : []),
]}
onChange={(kind) => patch({ kind })}
/>
<TextField
label={t`Text`}
value={overlay.text ?? ""}
maxLength={200}
onChange={(text) => patch({ text })}
/>
<SecondsField label={t`Start`} ms={overlay.atMs} onChange={(atMs) => patch({ atMs })} />
<SecondsField
label={t`Duration`}
ms={overlay.durationMs}
min={100}
onChange={(durationMs) => patch({ durationMs })}
/>
<Button
variant="ghost"
size="sm"
className="self-end"
onClick={() => {
onChange({
...edl,
overlays: edl.overlays.filter((candidate) => candidate.id !== overlay.id),
});
onClear();
}}
>
<Trash2 />
<Trans>Remove</Trans>
</Button>
</section>
);
}
const captions: EdlCaptions = edl.captions ?? {
mode: "none",
maxCharsPerLine: 32,
wordHighlight: false,
position: "lower",
};
const patch = (next: Partial<EdlCaptions>) =>
onChange({ ...edl, captions: { ...captions, ...next } });
return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="media-inspector">
<SelectField
label={t`Captions`}
value={captions.mode}
options={[
{ value: "burned", label: t`In video` },
{ value: "sidecar", label: t`Subtitle file` },
{ value: "both", label: t`Both` },
{ value: "none", label: t`Off` },
]}
onChange={(mode) => patch({ mode })}
/>
{captions.mode === "none" ? null : (
<>
<SelectField
label={t`Position`}
value={captions.position}
options={[
{ value: "upper", label: t`Top` },
{ value: "middle", label: t`Middle` },
{ value: "lower", label: t`Bottom` },
]}
onChange={(position) => patch({ position })}
/>
<NumberField
label={t`Characters per line`}
value={captions.maxCharsPerLine}
min={10}
max={60}
onChange={(maxCharsPerLine) => patch({ maxCharsPerLine })}
/>
{captionPresets.length > 0 ? (
<SelectField
label={t`Style`}
value={captions.presetId ?? ""}
options={[
{ value: "", label: t`Default` },
...captionPresets.map((preset) => ({ value: preset.id, label: preset.name })),
]}
onChange={(presetId) => patch({ presetId: presetId || undefined })}
/>
) : null}
<ToggleField
label={t`Highlight words`}
checked={captions.wordHighlight}
onChange={(wordHighlight) => patch({ wordHighlight })}
/>
</>
)}
</section>
);
}

View File

@ -0,0 +1,6 @@
import type { MediaViewProps } from "./nav";
/** Placeholder; the W3-web-library slice replaces this view. */
export function LibraryView(_props: MediaViewProps & Record<string, unknown>) {
return null;
}

View File

@ -0,0 +1,34 @@
import { useState } from "react";
import { BrandKitView } from "./BrandKitView";
import { CollectionView } from "./CollectionView";
import { EditorView } from "./EditorView";
import { LibraryView } from "./LibraryView";
import type { MediaView } from "./nav";
import { PeopleView } from "./PeopleView";
import { ProjectView } from "./ProjectView";
import { ResultsView } from "./ResultsView";
import { ReviewView } from "./ReviewView";
/** Media Studio surface. Chrome (header, close, tabs) belongs to the W3-web-library slice. */
export function MediaStudio({ onClose }: { onClose: () => void }) {
const [view, setView] = useState<MediaView>({ name: "library" });
const props = { navigate: setView, onClose };
switch (view.name) {
case "library":
return <LibraryView {...props} />;
case "collection":
return <CollectionView {...props} collectionId={view.collectionId} />;
case "people":
return <PeopleView {...props} />;
case "results":
return <ResultsView {...props} />;
case "project":
return <ProjectView {...props} projectId={view.projectId} />;
case "editor":
return <EditorView {...props} projectId={view.projectId} planId={view.planId} />;
case "review":
return <ReviewView {...props} projectId={view.projectId} renderId={view.renderId} />;
case "brandKit":
return <BrandKitView {...props} brandKitId={view.brandKitId} />;
}
}

View File

@ -0,0 +1,6 @@
import type { MediaViewProps } from "./nav";
/** Placeholder; the W3-web-library slice replaces this view. */
export function PeopleView(_props: MediaViewProps & Record<string, unknown>) {
return null;
}

View File

@ -0,0 +1,292 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type {
MediaEditPlan,
MediaProject,
MediaRender,
PlatformProfileSummary,
} from "@rakazo/contracts";
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@rakazo/ui-web";
import { Download, Palette, Pencil, Play, ShieldCheck, Sparkles, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { LoadingState } from "../../components/ai/primitives";
import {
ErrorLine,
errorMessage,
GateBadge,
ViewHeader,
} from "../../components/media/editor/chrome";
import {
downloadMediaFile,
renderPosterUrl,
renderVideoUrl,
useMediaObjectUrl,
usePolling,
} from "../../components/media/editor/media-files";
import { RenderVideo } from "../../components/media/editor/Player";
import { PublishPanel } from "../../components/media/editor/PublishPanel";
import type { Variant } from "../../components/media/editor/renders";
import {
anyRenderActive,
gateStatus,
isRenderActive,
projectVariants,
RENDER_POLL_MS,
} from "../../components/media/editor/renders";
import { edlDurationMs, formatMs } from "../../components/media/editor/timeline";
import { rpc } from "../../lib/rpc";
import type { MediaViewProps } from "./nav";
interface ProjectData {
project: MediaProject;
plans: MediaEditPlan[];
renders: MediaRender[];
}
/** A project's variants, one card per platform profile. */
export function ProjectView({ navigate, projectId }: MediaViewProps & { projectId: string }) {
const { t } = useLingui();
const [data, setData] = useState<ProjectData | null>(null);
const [profiles, setProfiles] = useState<Map<string, PlatformProfileSummary>>(new Map());
const [planning, setPlanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setData(await rpc.media.projects.get({ projectId }));
} catch (cause) {
setError(errorMessage(cause));
}
}, [projectId]);
useEffect(() => {
void load();
void rpc.media.profiles
.list({})
.then((list) => setProfiles(new Map(list.map((profile) => [profile.id, profile]))))
.catch(() => undefined);
}, [load]);
usePolling(load, data ? anyRenderActive(data.renders) : false, RENDER_POLL_MS);
if (!data) {
return (
<div className="flex flex-col gap-4 p-4">
<ErrorLine error={error} />
{error ? null : <Skeleton className="h-64 w-full" />}
</div>
);
}
const variants = projectVariants(data.project.profileIds, data.plans, data.renders);
const unplanned = variants.some((variant) => !variant.plan);
async function autoPlan() {
setPlanning(true);
setError(null);
try {
await rpc.media.projects.autoPlan({ projectId });
await load();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setPlanning(false);
}
}
return (
<div className="flex flex-col gap-4 p-4" data-testid="media-project">
<ViewHeader
title={data.project.title}
backLabel={t`Back to library`}
onBack={() => navigate({ name: "library" })}
>
{data.project.brandKitId ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Brand kit`}
onClick={() =>
navigate({ name: "brandKit", brandKitId: data.project.brandKitId ?? undefined })
}
>
<Palette />
</Button>
) : null}
{unplanned ? (
<Button size="sm" disabled={planning} onClick={autoPlan}>
{planning ? (
<LoadingState label={t`Planning`} />
) : (
<>
<Sparkles />
<Trans>Plan</Trans>
</>
)}
</Button>
) : null}
</ViewHeader>
<ErrorLine error={error} />
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(18rem,1fr))]">
{variants.map((variant) => (
<VariantCard
key={variant.profileId}
variant={variant}
profile={profiles.get(variant.profileId) ?? null}
navigate={navigate}
projectId={projectId}
onChanged={load}
/>
))}
</div>
</div>
);
}
function VariantCard({
variant,
profile,
navigate,
projectId,
onChanged,
}: {
variant: Variant;
profile: PlatformProfileSummary | null;
navigate: MediaViewProps["navigate"];
projectId: string;
onChanged: () => Promise<void>;
}) {
const { t } = useLingui();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { plan, draft, final } = variant;
// The final render once there is one, otherwise the draft.
const shown = final ?? draft;
const status = shown ? gateStatus(shown) : null;
const playable = shown?.status === "done" ? shown : null;
const videoUrl = useMediaObjectUrl(playable ? renderVideoUrl(playable) : null);
const posterUrl = useMediaObjectUrl(playable ? renderPosterUrl(playable) : null);
const fixRounds = shown?.gateReport?.autoFixRound ?? 0;
const finalVideo = final?.status === "done" ? renderVideoUrl(final) : null;
const draftApprovable =
plan !== null &&
draft?.status === "done" &&
draft.planId === plan.id &&
gateStatus(draft) !== "blocked" &&
!(final && (isRenderActive(final) || final.planId === plan.id));
async function act(work: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await work();
await onChanged();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(false);
}
}
const start = (quality: "draft" | "final") =>
plan ? act(() => rpc.media.renders.start({ planId: plan.id, quality })) : undefined;
return (
<Card data-testid="media-variant" data-profile={variant.profileId}>
<CardHeader className="flex flex-row items-center gap-2">
<CardTitle className="min-w-0 flex-1 truncate">
{profile?.name ?? variant.profileId}
</CardTitle>
{profile && (profile.unverified || profile.stale) ? (
<span
className="text-ui-caption text-muted-foreground"
title={t`Some limits of this profile are unverified or out of date.`}
>
<Trans>Unverified</Trans>
</span>
) : null}
{status ? <GateBadge status={status} /> : null}
</CardHeader>
<CardContent className="flex flex-col gap-3">
{shown && isRenderActive(shown) ? (
<div className="flex aspect-video items-center justify-center rounded-lg bg-muted">
<LoadingState
label={`${shown.status === "gating" ? t`Checking` : t`Rendering`} ${Math.round(shown.progress * 100)}%`}
startedAt={Date.parse(shown.createdAt)}
/>
</div>
) : playable ? (
<RenderVideo src={videoUrl} poster={posterUrl} label={t`Variant preview`} />
) : null}
{shown?.status === "failed" && shown.error ? (
<p className="text-ui-body-sm text-destructive">{shown.error}</p>
) : null}
<div className="flex items-center gap-2 text-ui-body-sm text-muted-foreground">
{plan ? <span>{formatMs(edlDurationMs(plan.edl))}</span> : <Trans>No plan yet</Trans>}
{fixRounds > 0 ? (
<span>
<Trans>Auto-fix {fixRounds}/3</Trans>
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{plan ? (
<Button
variant="outline"
size="sm"
onClick={() => navigate({ name: "editor", projectId, planId: plan.id })}
>
<Pencil />
<Trans>Edit</Trans>
</Button>
) : null}
{plan && !(draft && isRenderActive(draft)) ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => start("draft")}>
<Play />
<Trans>Draft</Trans>
</Button>
) : null}
{shown && isRenderActive(shown) ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => act(() => rpc.media.renders.cancel({ renderId: shown.id }))}
>
<X />
<Trans>Cancel</Trans>
</Button>
) : null}
{shown?.gateReport ? (
<Button
variant="outline"
size="sm"
onClick={() => navigate({ name: "review", projectId, renderId: shown.id })}
>
<Trans>Review</Trans>
</Button>
) : null}
{draftApprovable ? (
<Button size="sm" disabled={busy} onClick={() => start("final")}>
<ShieldCheck />
<Trans>Approve</Trans>
</Button>
) : null}
{finalVideo ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Download`}
onClick={() =>
act(() => downloadMediaFile(`${finalVideo}?download=1`, `${variant.profileId}.mp4`))
}
>
<Download />
</Button>
) : null}
</div>
<ErrorLine error={error} />
{final?.status === "done" ? <PublishPanel render={final} /> : null}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,6 @@
import type { MediaViewProps } from "./nav";
/** Placeholder; the W3-web-library slice replaces this view. */
export function ResultsView(_props: MediaViewProps & Record<string, unknown>) {
return null;
}

View File

@ -0,0 +1,597 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type {
AnonymizeMode,
Edl,
GateIssue,
MarkTreatment,
MediaCapabilities,
MediaEditPlan,
MediaMark,
MediaPerson,
MediaRender,
} from "@rakazo/contracts";
import { MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts";
import { Button, cn, NativeSelect, NativeSelectOption, Skeleton } from "@rakazo/ui-web";
import { Crosshair, Play, Plus, Save, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LoadingState } from "../../components/ai/primitives";
import {
ErrorLine,
errorMessage,
GateBadge,
ViewHeader,
} from "../../components/media/editor/chrome";
import {
renderPosterUrl,
renderVideoUrl,
useMediaObjectUrl,
usePolling,
} from "../../components/media/editor/media-files";
import type { PlayerHandle, SourceFrame } from "../../components/media/editor/Player";
import { RenderVideo, SourcePlayer } from "../../components/media/editor/Player";
import {
gateStatus,
isRenderActive,
RENDER_POLL_MS,
reviewedPlan,
sortedIssues,
} from "../../components/media/editor/renders";
import type { CoverageRow } from "../../components/media/editor/review-parts";
import { CoverageHeatmap, MaskBoxEditor } from "../../components/media/editor/review-parts";
import {
addRegion,
edlDurationMs,
formatMs,
regionBoxAt,
removeRegion,
setRegionKeyframe,
} from "../../components/media/editor/timeline";
import { rpc } from "../../lib/rpc";
import type { MediaViewProps } from "./nav";
type Mode = "after" | "before";
/**
* Review of one render (spec 7 Prüfansicht): gate blockers that jump the
* player, coverage per person and mark, before/after, manual masks, and
* per-person and per-mark treatment.
*/
export function ReviewView({
navigate,
projectId,
renderId,
}: MediaViewProps & { projectId: string; renderId: string }) {
const { t } = useLingui();
const [render, setRender] = useState<MediaRender | null>(null);
const [plans, setPlans] = useState<MediaEditPlan[]>([]);
const [edl, setEdl] = useState<Edl | null>(null);
const [savedPlan, setSavedPlan] = useState<MediaEditPlan | null>(null);
const [dirty, setDirty] = useState(false);
const [people, setPeople] = useState<MediaPerson[]>([]);
const [marks, setMarks] = useState<MediaMark[]>([]);
const [capabilities, setCapabilities] = useState<MediaCapabilities | null>(null);
const [mode, setMode] = useState<Mode>("after");
const [marking, setMarking] = useState(false);
const [selectedMask, setSelectedMask] = useState<string | null>(null);
const [frame, setFrame] = useState<SourceFrame | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const renderPlayer = useRef<PlayerHandle>(null);
const sourcePlayer = useRef<PlayerHandle>(null);
const kindLabels = useMarkKindLabels();
const loadRender = useCallback(async () => {
try {
setRender(await rpc.media.renders.get({ renderId }));
} catch (cause) {
setError(errorMessage(cause));
}
}, [renderId]);
const loadMarks = useCallback(async () => {
setMarks(await rpc.media.marks.list({}));
}, []);
useEffect(() => {
setEdl(null);
setSavedPlan(null);
setDirty(false);
void loadRender();
void rpc.media.projects
.get({ projectId })
.then((data) => setPlans(data.plans))
.catch((cause) => setError(errorMessage(cause)));
void rpc.media.people
.list({})
.then(setPeople)
.catch(() => undefined);
void loadMarks().catch(() => undefined);
void rpc.media
.capabilities({})
.then(setCapabilities)
.catch(() => undefined);
}, [projectId, loadRender, loadMarks]);
usePolling(loadRender, render ? isRenderActive(render) : false, RENDER_POLL_MS);
const plan = useMemo(
() => savedPlan ?? (render ? reviewedPlan(render, plans) : null),
[savedPlan, render, plans],
);
useEffect(() => {
if (plan && !dirty) setEdl(plan.edl);
}, [plan, dirty]);
const videoUrl = useMediaObjectUrl(render?.status === "done" ? renderVideoUrl(render) : null);
const posterUrl = useMediaObjectUrl(render?.status === "done" ? renderPosterUrl(render) : null);
const hasVideo = render?.status === "done" && renderVideoUrl(render) !== null;
const shownMode: Mode = hasVideo ? mode : "before";
if (!render || !edl || !plan) {
return (
<div className="flex flex-col gap-4 p-4">
<ErrorLine error={error} />
{error ? null : <Skeleton className="h-64 w-full" />}
</div>
);
}
const issues = sortedIssues(render.gateReport?.issues ?? []);
const status = gateStatus(render);
const round = render.gateReport?.autoFixRound ?? 0;
const durationMs = edlDurationMs(edl);
const seek = (ms: number) => {
if (shownMode === "after") renderPlayer.current?.seek(ms);
else sourcePlayer.current?.seek(ms);
};
const changeEdl = (next: Edl) => {
setEdl(next);
setDirty(true);
};
async function act(key: string, work: () => Promise<void>) {
setBusy(key);
setError(null);
try {
await work();
} catch (cause) {
setError(errorMessage(cause));
} finally {
setBusy(null);
}
}
const savePlan = () =>
act("save", async () => {
const result = await rpc.media.plans.submit({ projectId, edl, rationale: null });
if (!result.ok) {
setError(result.errors.map((item) => item.message).join(" "));
return;
}
setSavedPlan(result.plan);
setPlans((current) => [...current, result.plan]);
setDirty(false);
});
const rerender = () =>
act("render", async () => {
const started = await rpc.media.renders.start({ planId: plan.id, quality: "draft" });
navigate({ name: "review", projectId, renderId: started.id });
});
// People in this video: face redactions by person plus anyone the gate named.
const personIds = new Set<string>();
for (const face of edl.redactions.faces) {
if ("personId" in face.target) personIds.add(face.target.personId);
}
for (const issue of issues) if (issue.personId) personIds.add(issue.personId);
const shownPeople = people.filter((person) => personIds.has(person.id));
const assetIds = new Set(edl.tracks.video.map((clip) => clip.assetId));
const plannedMarks = new Set(edl.redactions.marks.map((mark) => mark.markId));
const shownMarks = marks.filter(
(mark) =>
(mark.assetId !== null && assetIds.has(mark.assetId)) ||
(mark.personId !== null && personIds.has(mark.personId)) ||
plannedMarks.has(mark.id) ||
issues.some((issue) => issue.markId === mark.id),
);
const coverage: CoverageRow[] = [
...shownPeople.map((person) => ({
key: `person-${person.id}`,
label: person.displayName,
issues: issues.filter((issue) => issue.personId === person.id),
})),
...shownMarks.map((mark) => ({
key: `mark-${mark.id}`,
label: mark.label ?? kindLabels[mark.kind],
issues: issues.filter((issue) => issue.markId === mark.id),
})),
];
const unassigned = issues.filter(
(issue) => issue.check === "anonymization" && !issue.personId && !issue.markId,
);
if (unassigned.length > 0) {
coverage.push({ key: "unassigned", label: t`Unknown faces`, issues: unassigned });
}
const clipRegions = frame
? edl.redactions.regions.filter((region) => region.clipId === frame.clipId)
: [];
return (
<div className="flex flex-col gap-4 p-4" data-testid="media-review">
<ViewHeader
title={`${render.profileId} · ${render.quality === "final" ? t`Final` : t`Draft`}`}
backLabel={t`Back to project`}
onBack={() => navigate({ name: "project", projectId })}
>
{status ? <GateBadge status={status} /> : null}
</ViewHeader>
{isRenderActive(render) ? (
<LoadingState
label={`${render.status === "gating" ? t`Checking` : t`Rendering`} ${Math.round(render.progress * 100)}%`}
startedAt={Date.parse(render.createdAt)}
/>
) : null}
{round > 0 ? (
<p className="text-ui-body-sm text-muted-foreground" data-testid="media-autofix">
<Trans>
Auto-fix {round}/{MAX_AUTO_FIX_ROUNDS}
</Trans>
{plan.createdBy === "agent" && plan.rationale ? ` · ${plan.rationale}` : null}
</p>
) : null}
{render.status === "failed" && render.error ? (
<p className="text-ui-body-sm text-destructive">{render.error}</p>
) : null}
<div className="mx-auto flex w-full max-w-2xl flex-col gap-2">
{hasVideo ? (
<div className="flex gap-1 self-center rounded-lg bg-muted p-0.5">
{(["before", "after"] as const).map((value) => (
<Button
key={value}
size="xs"
variant={mode === value ? "secondary" : "ghost"}
aria-pressed={mode === value}
onClick={() => setMode(value)}
>
{value === "before" ? t`Before` : t`After`}
</Button>
))}
</div>
) : null}
{shownMode === "after" ? (
<RenderVideo
src={videoUrl}
poster={posterUrl}
handleRef={renderPlayer}
label={t`Rendered video`}
/>
) : (
<SourcePlayer
edl={edl}
handleRef={sourcePlayer}
onFrame={setFrame}
label={t`Source video`}
>
{frame
? clipRegions.map((region) => (
<MaskBoxEditor
key={region.id}
box={regionBoxAt(region, frame.sourceMs)}
label={t`Mask ${region.id}`}
selected={selectedMask === region.id}
onSelect={() => setSelectedMask(region.id)}
onChange={(box) =>
changeEdl(setRegionKeyframe(edl, region.id, frame.sourceMs, box))
}
/>
))
: null}
{marking && frame ? (
<button
type="button"
aria-label={t`Mark this spot`}
className="pointer-events-auto absolute inset-0 cursor-crosshair"
onClick={(event) => {
const box = event.currentTarget.getBoundingClientRect();
const point = {
x: Math.min(1, Math.max(0, (event.clientX - box.left) / box.width)),
y: Math.min(1, Math.max(0, (event.clientY - box.top) / box.height)),
};
setMarking(false);
void act("mark", async () => {
await rpc.media.marks.create({
kind: "custom",
label: null,
personId: null,
assetId: frame.assetId,
prompt: { tMs: frame.sourceMs, point },
treatment: "blur",
});
await loadMarks();
});
}}
/>
) : null}
</SourcePlayer>
)}
{shownMode === "before" ? (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!frame}
onClick={() => {
if (!frame) return;
const added = addRegion(edl, frame.clipId, frame.sourceMs);
changeEdl(added.edl);
setSelectedMask(added.id);
}}
>
<Plus />
<Trans>Mask</Trans>
</Button>
<Button
variant={marking ? "secondary" : "outline"}
size="sm"
aria-pressed={marking}
disabled={!frame || busy === "mark"}
onClick={() => setMarking((value) => !value)}
>
{busy === "mark" ? (
<LoadingState label={t`Marking`} />
) : (
<>
<Crosshair />
<Trans>Mark</Trans>
</>
)}
</Button>
{selectedMask && edl.redactions.regions.some((r) => r.id === selectedMask) ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
changeEdl(removeRegion(edl, selectedMask));
setSelectedMask(null);
}}
>
<Trash2 />
<Trans>Remove mask</Trans>
</Button>
) : null}
</div>
) : null}
{dirty ? (
<Button size="sm" className="self-start" disabled={busy !== null} onClick={savePlan}>
<Save />
<Trans>Save</Trans>
</Button>
) : savedPlan ? (
<Button size="sm" className="self-start" disabled={busy !== null} onClick={rerender}>
<Play />
<Trans>Draft</Trans>
</Button>
) : null}
<ErrorLine error={error} />
</div>
<CoverageHeatmap rows={coverage} durationMs={durationMs} onSeek={seek} />
{issues.length > 0 ? (
<ol className="flex flex-col divide-y divide-border rounded-lg border border-border">
{issues.map((issue, index) => (
<IssueRow
key={`${issue.code}-${issue.atMs ?? "x"}-${index}`}
issue={issue}
onSeek={seek}
/>
))}
</ol>
) : null}
{shownPeople.length > 0 ? (
<section className="flex flex-col gap-2">
{shownPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
syntheticAvailable={capabilities?.operations.synthesizeIdentity?.available === true}
needsConsent={issues.some(
(issue) => issue.check === "consent" && issue.personId === person.id,
)}
onConsent={() => navigate({ name: "people" })}
onChange={(next) =>
setPeople((current) => current.map((p) => (p.id === next.id ? next : p)))
}
/>
))}
</section>
) : null}
{shownMarks.length > 0 ? (
<section className="flex flex-col gap-2">
{shownMarks.map((mark) => (
<MarkRow
key={mark.id}
mark={mark}
onChange={(next) =>
setMarks((current) => current.map((m) => (m.id === next.id ? next : m)))
}
/>
))}
</section>
) : null}
</div>
);
}
function IssueRow({ issue, onSeek }: { issue: GateIssue; onSeek: (ms: number) => void }) {
const { t } = useLingui();
const timed = issue.atMs !== undefined;
return (
<li data-testid="media-gate-issue" data-severity={issue.severity}>
<button
type="button"
disabled={!timed}
onClick={() => timed && onSeek(issue.atMs ?? 0)}
className="flex w-full items-start gap-3 px-3 py-2 text-left enabled:hover:bg-muted/50"
>
<span
aria-hidden
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
issue.severity === "block" ? "bg-destructive" : "bg-warning",
)}
/>
<span className="w-14 shrink-0 font-mono text-ui-body-sm tabular-nums text-muted-foreground">
{timed ? formatMs(issue.atMs ?? 0) : "—"}
</span>
<span className="min-w-0 flex-1 text-ui-body-sm">
<span className="sr-only">{issue.severity === "block" ? t`Blocker` : t`Warning`}: </span>
{issue.message}
</span>
</button>
</li>
);
}
function PersonRow({
person,
syntheticAvailable,
needsConsent,
onConsent,
onChange,
}: {
person: MediaPerson;
syntheticAvailable: boolean;
needsConsent: boolean;
onConsent: () => void;
onChange: (person: MediaPerson) => void;
}) {
const { t } = useLingui();
const [error, setError] = useState<string | null>(null);
const modes: Array<{ value: AnonymizeMode; label: string }> = [
{ value: "none", label: t`Visible` },
{ value: "blur", label: t`Blur` },
{ value: "pixelate", label: t`Pixelate` },
{ value: "solid", label: t`Solid` },
{ value: "sticker", label: t`Sticker` },
{ value: "partial_eyes", label: t`Eyes` },
{ value: "partial_upper", label: t`Upper face` },
{ value: "partial_lower", label: t`Lower face` },
{ value: "reframe_out", label: t`Crop out` },
...(syntheticAvailable || person.anonymizeByDefault === "synthetic_identity"
? [{ value: "synthetic_identity" as const, label: t`Synthetic face (experimental)` }]
: []),
];
return (
<div className="flex flex-wrap items-center gap-2" data-testid="media-review-person">
<span className="min-w-0 flex-1 truncate text-ui-body">{person.displayName}</span>
{needsConsent ? (
<Button variant="outline" size="sm" onClick={onConsent}>
<Trans>Record consent</Trans>
</Button>
) : null}
<NativeSelect
size="sm"
aria-label={t`Anonymization for ${person.displayName}`}
value={person.anonymizeByDefault}
onChange={(event) => {
const anonymizeByDefault = event.target.value as AnonymizeMode;
setError(null);
void rpc.media.people
.update({ personId: person.id, anonymizeByDefault })
.then(onChange)
.catch((cause) => setError(errorMessage(cause)));
}}
>
{modes.map((mode) => (
<NativeSelectOption key={mode.value} value={mode.value}>
{mode.label}
</NativeSelectOption>
))}
</NativeSelect>
<ErrorLine error={error} />
</div>
);
}
function MarkRow({ mark, onChange }: { mark: MediaMark; onChange: (mark: MediaMark) => void }) {
const { t } = useLingui();
const [error, setError] = useState<string | null>(null);
const labels = useTreatmentLabels();
const kindLabels = useMarkKindLabels();
const update = (treatment: MarkTreatment) => {
setError(null);
void rpc.media.marks
.update({ markId: mark.id, treatment })
.then(onChange)
.catch((cause) => setError(errorMessage(cause)));
};
const fallback =
mark.suggestedFallback && mark.suggestedFallback !== mark.treatment
? mark.suggestedFallback
: null;
return (
<div className="flex flex-wrap items-center gap-2" data-testid="media-review-mark">
<span className="min-w-0 flex-1 truncate text-ui-body">
{mark.label ?? kindLabels[mark.kind]}
</span>
{mark.qualityScore !== null ? (
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">
{Math.round(mark.qualityScore * 100)}%
</span>
) : null}
{fallback ? (
<Button variant="outline" size="sm" onClick={() => update(fallback)}>
{t`Use ${labels[fallback]}`}
</Button>
) : null}
<NativeSelect
size="sm"
aria-label={t`Treatment`}
value={mark.treatment}
onChange={(event) => update(event.target.value as MarkTreatment)}
>
{(Object.keys(labels) as MarkTreatment[]).map((value) => (
<NativeSelectOption key={value} value={value}>
{labels[value]}
</NativeSelectOption>
))}
</NativeSelect>
<ErrorLine error={error} />
</div>
);
}
function useTreatmentLabels(): Record<MarkTreatment, string> {
const { t } = useLingui();
return {
inpaint: t`Remove`,
blur: t`Blur`,
cover: t`Cover`,
reframe_out: t`Crop out`,
none: t`Keep`,
};
}
function useMarkKindLabels(): Record<MediaMark["kind"], string> {
const { t } = useLingui();
return {
tattoo: t`Tattoo`,
birthmark: t`Birthmark`,
scar: t`Scar`,
jewelry: t`Jewelry`,
background_detail: t`Background detail`,
license_plate: t`License plate`,
custom: t`Marked spot`,
};
}

View File

@ -0,0 +1,14 @@
/** Screens inside Media Studio. Every view receives `navigate` to move between them. */
export type MediaView =
| { name: "library" }
| { name: "collection"; collectionId: string }
| { name: "people" }
| { name: "results" }
| { name: "project"; projectId: string }
| { name: "editor"; projectId: string; planId: string }
| { name: "review"; projectId: string; renderId: string }
| { name: "brandKit"; brandKitId?: string };
export interface MediaViewProps {
navigate: (view: MediaView) => void;
}

View File

@ -10,6 +10,8 @@ import {
createCloudAgentConnection,
createConnectorStack,
createJobReconciler,
createMediaJobHandlers,
createMediaServices,
createMessagingContextLoader,
createPostgresReconciliationLeadership,
createProfileConsolidationScheduler,
@ -31,6 +33,7 @@ import {
LocalArtifactStore,
McpConnector,
McpOAuthBroker,
mediaConfigFromEnv,
messagingEnvFromProcess,
messagingPlatformsFromEnv,
PiAgentRuntime,
@ -201,6 +204,25 @@ async function main() {
cloudAgent,
});
const mediaAbort = new AbortController();
const mediaServices = createMediaServices({
config: mediaConfigFromEnv(process.env),
prisma,
jobs,
artifacts,
runtime,
realtime,
resolveModel: (scope) => executor.resolveModel(scope),
});
const mediaJobs = mediaServices
? createMediaJobHandlers(mediaServices, {
signal: mediaAbort.signal,
limits: {
gpu: parsePositiveInteger(process.env.MEDIA_GPU_CONCURRENCY, 1),
cpu: parsePositiveInteger(process.env.MEDIA_CPU_CONCURRENCY, 2),
},
})
: null;
const jobHandlers = createBackgroundJobHandlers({
executor,
prisma,
@ -208,6 +230,7 @@ async function main() {
home,
jobs,
events,
...(mediaJobs ? { media: mediaJobs.handlers } : {}),
workerId: process.pid.toString(),
runtime,
secretStore: secrets,
@ -235,6 +258,9 @@ async function main() {
await new Promise((resolve) => setTimeout(resolve, databaseCapacityBackoffMs(attempt)));
}
}
if (mediaJobs) {
void mediaJobs.resume().catch((error) => logger.error("media ingest resume failed", error));
}
const reconciler = createJobReconciler({
prisma,
jobs,
@ -253,6 +279,7 @@ async function main() {
stopping = true;
try {
await reconciler.stop();
mediaAbort.abort();
await jobHost.stop();
await jobs.close();
await realtime.close();

View File

@ -129,6 +129,28 @@ services:
data-init:
condition: service_completed_successfully
media-worker:
# Optional Media Studio sidecar: `docker compose --profile media up`.
# No published port; the API and worker reach it as http://media-worker:8790.
profiles: ["media"]
build:
context: ../media-worker
args:
ACCELERATOR: ${MEDIA_ACCELERATOR:-cpu}
image: rakazo/media-worker:local
environment:
MEDIA_WORKER_HOST: "0.0.0.0"
MEDIA_WORKER_PORT: "8790"
MEDIA_WORKER_TOKEN: ${MEDIA_WORKER_TOKEN:?Set MEDIA_WORKER_TOKEN in .env}
MEDIA_ROOT: /data/media
MEDIA_MODELS_DIR: /models
HTTP_PROXY: ${HTTP_PROXY:-${http_proxy:-}}
HTTPS_PROXY: ${HTTPS_PROXY:-${https_proxy:-}}
NO_PROXY: ${NO_PROXY:-${no_proxy:-}}
volumes:
- ../../data/media:/data/media
- media-models:/models
web:
build:
context: ../..
@ -144,4 +166,5 @@ services:
- api
volumes:
media-models:
pgdata:

View File

@ -0,0 +1,7 @@
.pytest_cache
.ruff_cache
.mypy_cache
.venv
.venv-ml
**/__pycache__
**/*.pyc

5
infra/media-worker/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.pytest_cache/
.venv/
.venv
.venv-ml
__pycache__/

View File

@ -0,0 +1,68 @@
# syntax=docker/dockerfile:1.7
# Media Studio inference sidecar.
# docker build --build-arg ACCELERATOR=cpu|cuda|rocm -t rakazo/media-worker infra/media-worker
# docker build --target test -t rakazo/media-worker:test infra/media-worker # offline pytest
# Model weights are not baked in; they download into /models on first use.
# Behind a TLS-intercepting proxy, pass its CA with --secret id=pip_ca,src=<ca.pem>.
ARG ACCELERATOR=cpu
FROM python:3.11-slim-bookworm@sha256:2333bd330d12de02514770b3585cad313644316047cdee24a7acfdece6de6efb AS runtime
ARG ACCELERATOR
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TQDM_DISABLE=1 \
MEDIA_ACCELERATOR=${ACCELERATOR} \
MEDIA_MODELS_DIR=/models \
MEDIA_WORKER_PORT=8790
RUN case "${ACCELERATOR}" in cpu|cuda|rocm) ;; *) echo "ACCELERATOR must be cpu, cuda or rocm" >&2; exit 1 ;; esac \
&& apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv/media-worker
COPY requirements.txt requirements-ml-cpu.txt requirements-ml-cuda.txt requirements-ml-rocm.txt ./
# faster-whisper depends on the CPU onnxruntime wheel; the CUDA image swaps in
# onnxruntime-gpu. SAM2 is an sdist built against the installed torch, without
# its optional CUDA extension.
RUN --mount=type=secret,id=pip_ca,required=false \
if [ -s /run/secrets/pip_ca ]; then export PIP_CERT=/run/secrets/pip_ca; fi \
&& pip install -r requirements.txt -r "requirements-ml-${ACCELERATOR}.txt" \
&& if [ "${ACCELERATOR}" = "cuda" ]; then \
pip uninstall -y onnxruntime && pip install --no-deps --force-reinstall onnxruntime-gpu==1.30.0; \
fi \
&& SAM2_BUILD_CUDA=0 pip install --no-build-isolation --no-deps sam2==1.1.0 \
&& python -c "import cv2, scenedetect, librosa, onnxruntime, faster_whisper, torch, sam2, tokenizers"
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin media \
&& mkdir -p /models \
&& chown media:media /models
VOLUME ["/models"]
COPY app ./app
USER media
EXPOSE 8790
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ.get('MEDIA_WORKER_PORT', '8790') + '/healthz', timeout=4)"]
CMD ["python", "-m", "app"]
# Offline test image: same runtime plus pytest; fake backends, no weights.
FROM runtime AS test
USER root
COPY requirements-dev.txt pytest.ini ./
RUN --mount=type=secret,id=pip_ca,required=false \
if [ -s /run/secrets/pip_ca ]; then export PIP_CERT=/run/secrets/pip_ca; fi \
&& pip install -r requirements-dev.txt
COPY tests ./tests
USER media
CMD ["python", "-m", "pytest", "-q"]
FROM runtime

View File

@ -0,0 +1,53 @@
# Licenses: models and libraries of the media sidecar
Checked October 2026 against the upstream LICENSE files and model cards linked
below. "Commercial use" means the published code or weights license permits it.
Training-data terms were not reviewed legally; where provenance is unclear the
backend is optional, never a default. Weights are downloaded at runtime from the
pinned revision (see `app/models.py`) and are never part of this repository or
the image.
## Models
| Name | Purpose | License | Commercial use | Default / optional | Source |
|---|---|---|---|---|---|
| OpenCV YuNet 2023mar | Face detection, 5-point landmarks (`detectFaces`, `trackFaces`, `faceLandmarks`) | MIT | yes | default | https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet |
| Whisper small (CTranslate2 conversion by Systran) | Transcription with word timestamps (`transcribe`) | MIT | yes | default | https://huggingface.co/Systran/faster-whisper-small, https://github.com/openai/whisper |
| SigLIP base patch16-224 (ONNX export by Xenova) | Frame embeddings (`embedFrames`); zero-shot content scores (`classifyContent`, experimental) | Apache-2.0 | yes | default | https://huggingface.co/google/siglip-base-patch16-224, https://huggingface.co/Xenova/siglip-base-patch16-224 |
| SAM 2.1 Hiera tiny | Click/box segmentation with video propagation (`segmentPrompted`, experimental) | Apache-2.0 | yes | default | https://github.com/facebookresearch/sam2, https://huggingface.co/facebook/sam2.1-hiera-tiny |
| Big-LaMa (ONNX export by Carve) | Per-frame inpainting with optical-flow smoothing (`inpaintVideo`, experimental) | Apache-2.0 | yes | default | https://github.com/advimman/lama, https://huggingface.co/Carve/LaMa-ONNX |
| OpenCV SFace 2021dec | Identity embeddings for local person clustering (`trackFaces.embedding`) | Apache-2.0 (weights); training-data provenance unverified | unclear | optional (`MEDIA_OPTIONAL_BACKENDS=faceIdentity`) | https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface |
| MediaPipe Face Landmarker | Dense face landmarks | Apache-2.0 | yes | not used: the `mediapipe` wheel pulls the GUI OpenCV build; YuNet's 5 points cover the partial modes | https://github.com/google-ai-edge/mediapipe |
| OpenCLIP (LAION weights) | Frame embeddings | MIT | yes | not used: SigLIP runs on ONNX Runtime without torch | https://github.com/mlfoundations/open_clip |
| NudeNet v3 | Nudity detection | AGPL-3.0 | copyleft, not shipped | not used | https://github.com/notAI-tech/NudeNet |
| ProPainter | Video inpainting | S-Lab License 1.0 (non-commercial) | no | not used; never default | https://github.com/sczhou/ProPainter |
| InsightFace model zoo (SCRFD, ArcFace, inswapper) | Face detection, recognition, swap | code MIT; models non-commercial research only | no | not used; never default | https://github.com/deepinsight/insightface |
| Synthetic identity generator + swap | `synthesizeIdentity` | none found with a commercial license | — | unavailable: always 501 `unsupported` | — |
## Methods without weights
| Name | Purpose | License | Commercial use | Default / optional | Source |
|---|---|---|---|---|---|
| PySceneDetect (AdaptiveDetector) | Shot detection (`detectScenes`) | BSD-3-Clause | yes | default | https://github.com/Breakthrough/PySceneDetect |
| IoU tracker (ByteTrack-style, in `app/ops/faces.py`) | Face tracking | this repository | yes | default | — |
| Spectral residual saliency (Hou & Zhang 2007, in `app/ops/saliency.py`) | `saliency` | this repository | yes | default | https://doi.org/10.1109/CVPR.2007.383267 |
| FFmpeg `ebur128`, `silencedetect` | Loudness, true peak, silences (`audioFeatures`) | LGPL-2.1+ / GPL-2+ (Debian build with libx264) | yes, as a separate process | default | https://ffmpeg.org/legal.html |
| librosa | Beat and tempo tracking (`audioFeatures`) | ISC | yes | default | https://github.com/librosa/librosa |
## Libraries
| Name | License | Commercial use |
|---|---|---|
| FastAPI | MIT | yes |
| Uvicorn | BSD-3-Clause | yes |
| Pydantic | MIT | yes |
| NumPy | BSD-3-Clause | yes |
| OpenCV (opencv-python-headless) | Apache-2.0 (wheel bundles FFmpeg under LGPL) | yes |
| soundfile | BSD-3-Clause | yes |
| PyTorch, torchvision | BSD-3-Clause (bundles permissive third-party code: Apache-2.0, MIT, BSL-1.0) | yes |
| ONNX Runtime | MIT | yes |
| faster-whisper | MIT | yes |
| CTranslate2 | MIT | yes |
| Hugging Face tokenizers | Apache-2.0 | yes |
| SAM 2 package (`sam2`) | Apache-2.0 | yes |
| Hydra, iopath | MIT | yes |

View File

@ -0,0 +1,74 @@
# media-worker
Local inference sidecar for Media Studio (Python 3.11, FastAPI). It implements
every `MediaInferenceProvider` operation (`packages/adapter-kit/src/media.ts`)
behind HTTP API v1; the TypeScript client is `packages/adapters/src/media-sidecar.ts`.
## API
- `GET /healthz` → `{"ok": true, "version": "..."}` (no token)
- `GET /v1/capabilities` → `MediaCapabilities`
- `POST /v1/<operation>` with the TypeScript request object (minus `signal`)
- Errors: `{"error": {"code", "message", "operation"}}` with
`bad_request`/`invalid_path` 400, `unauthorized` 401, `too_large` 413,
`unsupported` 501, `busy` 503, `internal` 500.
Every route but `/healthz` needs `Authorization: Bearer $MEDIA_WORKER_TOKEN`.
Paths must be absolute and resolve (symlinks included) inside `MEDIA_ROOT`.
A request stops when its client disconnects (between frames or chunks) or
after `MEDIA_MAX_SECONDS`.
## Configuration
| Variable | Default | |
|---|---|---|
| `MEDIA_WORKER_TOKEN` | — | required, ≥ 32 chars (`MEDIA_WORKER_INSECURE_DEV=1` lifts this for tests) |
| `MEDIA_ROOT` | — | required; mount it at the same absolute path as the API's `DATA_DIR/media` |
| `MEDIA_MODELS_DIR` | `/models` | weight cache (volume) |
| `MEDIA_WORKER_HOST` / `MEDIA_WORKER_PORT` | `127.0.0.1` / `8790` | |
| `MEDIA_GPU_CONCURRENCY` | `1` | model operations running at once |
| `MEDIA_CPU_CONCURRENCY` | `2` | scenes, redact, audio, saliency running at once |
| `MEDIA_QUEUE_SIZE` | `8` | waiting requests per gate before `503 busy` |
| `MEDIA_MAX_INPUT_BYTES` | 20 GiB | larger inputs → 413 |
| `MEDIA_MAX_SECONDS` | 14400 | longer inputs → 413; longer processing is cancelled |
| `MEDIA_MODEL_DOWNLOAD` | `1` | `0` = offline; missing weights → 501 |
| `MEDIA_OPTIONAL_BACKENDS` | empty | opt-in for backends that are not clean for commercial use, e.g. `faceIdentity` |
| `MEDIA_ACCELERATOR` | build arg | `cpu`, `cuda` or `rocm`; falls back to cpu when no GPU is visible |
## Models
Backends, licenses, URLs, SHA256 and sizes are pinned in `app/models.py`; the
license review is in `LICENSES.md`. Weights download on first use into
`MEDIA_MODELS_DIR/<backend>/`, are checked against the pinned SHA256 and size,
and are deleted on mismatch (the operation then reports 501). Warm the volume
ahead of time with `python -m app prefetch`.
`synthesizeIdentity` always answers 501: there is no commercially licensed
generator and swap pipeline, and the request schema rejects any extra field,
so a target-face image cannot be passed.
## Redaction geometry
Boxes interpolate linearly between keyframes; a single keyframe is a static
region for the whole clip. `paddingPct` grows the box by that share of its
size, `featherPct` fades the effect outward over that share of the shorter
side (the region itself stays fully covered). Partial modes use the first two
landmarks as eyes and the third as the nose. Output is FFV1 for `.mkv`,
otherwise x264 CRF 12; audio is copied and metadata dropped. Any frame error is
a 500 and nothing is left at `outPath`.
## Build and test
```sh
docker build --build-arg ACCELERATOR=cpu -t rakazo/media-worker infra/media-worker
docker build --target test -t rakazo/media-worker:test infra/media-worker
docker run --rm rakazo/media-worker:test # offline pytest, fake model backends
```
Without Docker: `pip install -r requirements.txt -r requirements-dev.txt` and
`python -m pytest` in this directory. Operations whose ML packages are missing
report `available: false`.
CUDA uses the torch cu126 wheels and `onnxruntime-gpu`. ROCm uses the torch
rocm7.1 wheels for SAM 2; ONNX Runtime and CTranslate2 have no ROCm wheels on
PyPI, so SigLIP, LaMa and Whisper run on the CPU there.

View File

@ -0,0 +1,3 @@
"""Local Media Studio inference sidecar."""
VERSION = "0.1.0"

View File

@ -0,0 +1,35 @@
"""Entry point: `python -m app` serves the API, `python -m app prefetch` warms the model volume."""
from __future__ import annotations
import logging
import sys
import uvicorn
from .config import ConfigError, Settings
from .main import create_app
from .models import Registry
def main(argv: list[str]) -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
try:
settings = Settings.from_env()
except ConfigError as error:
print(f"media-worker: {error}", file=sys.stderr)
return 2
if argv[1:] == ["prefetch"]:
Registry(settings).prefetch()
return 0
if argv[1:]:
print("usage: python -m app [prefetch]", file=sys.stderr)
return 2
uvicorn.run(
create_app(settings), host=settings.host, port=settings.port, log_level="info", access_log=False
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@ -0,0 +1,22 @@
"""Bearer-token check for every route except /healthz."""
from __future__ import annotations
import hmac
from fastapi import Request
from .errors import MediaError
def require_token(request: Request) -> None:
expected: str = request.app.state.settings.token
header = request.headers.get("authorization", "")
scheme, _, presented = header.partition(" ")
if (
scheme.lower() != "bearer"
or not presented
or not expected
or not hmac.compare_digest(presented.strip().encode(), expected.encode())
):
raise MediaError("unauthorized", "missing or invalid bearer token")

View File

@ -0,0 +1,80 @@
"""Sidecar settings, read once from the environment."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
MIN_TOKEN_LENGTH = 32
GIB = 1024**3
class ConfigError(Exception):
pass
def _int(env: dict[str, str], name: str, default: int, minimum: int = 0) -> int:
raw = env.get(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError as error:
raise ConfigError(f"{name} must be an integer") from error
if value < minimum:
raise ConfigError(f"{name} must be >= {minimum}")
return value
@dataclass(frozen=True)
class Settings:
token: str
media_root: str
models_dir: str
host: str = "127.0.0.1"
port: int = 8790
accelerator: str = "cpu"
gpu_concurrency: int = 1
cpu_concurrency: int = 2
queue_size: int = 8
max_input_bytes: int = 20 * GIB
max_seconds: int = 4 * 3600
allow_model_download: bool = True
# Backends that are not licensed for commercial use stay off unless named here.
optional_backends: frozenset[str] = field(default_factory=frozenset)
insecure_dev: bool = False
@staticmethod
def from_env(env: dict[str, str] | None = None) -> Settings:
env = dict(os.environ if env is None else env)
insecure = env.get("MEDIA_WORKER_INSECURE_DEV", "") == "1"
token = env.get("MEDIA_WORKER_TOKEN", "")
if len(token) < MIN_TOKEN_LENGTH and not insecure:
raise ConfigError(f"MEDIA_WORKER_TOKEN must be at least {MIN_TOKEN_LENGTH} characters")
media_root = env.get("MEDIA_ROOT", "").strip()
if not media_root or not os.path.isabs(media_root):
raise ConfigError("MEDIA_ROOT must be an absolute path")
if not os.path.isdir(media_root):
raise ConfigError("MEDIA_ROOT does not exist")
accelerator = env.get("MEDIA_ACCELERATOR", "cpu").strip() or "cpu"
if accelerator not in ("cpu", "cuda", "rocm"):
raise ConfigError("MEDIA_ACCELERATOR must be cpu, cuda or rocm")
optional = frozenset(
item.strip() for item in env.get("MEDIA_OPTIONAL_BACKENDS", "").split(",") if item.strip()
)
return Settings(
token=token,
media_root=media_root,
models_dir=env.get("MEDIA_MODELS_DIR", "/models").strip() or "/models",
host=env.get("MEDIA_WORKER_HOST", "127.0.0.1").strip() or "127.0.0.1",
port=_int(env, "MEDIA_WORKER_PORT", 8790, 1),
accelerator=accelerator,
gpu_concurrency=_int(env, "MEDIA_GPU_CONCURRENCY", 1, 1),
cpu_concurrency=_int(env, "MEDIA_CPU_CONCURRENCY", 2, 1),
queue_size=_int(env, "MEDIA_QUEUE_SIZE", 8, 0),
max_input_bytes=_int(env, "MEDIA_MAX_INPUT_BYTES", 20 * GIB, 1),
max_seconds=_int(env, "MEDIA_MAX_SECONDS", 4 * 3600, 1),
allow_model_download=env.get("MEDIA_MODEL_DOWNLOAD", "1") != "0",
optional_backends=optional,
insecure_dev=insecure,
)

View File

@ -0,0 +1,41 @@
"""Hardware video encoders that actually encode a frame on this host."""
from __future__ import annotations
import functools
import os
import re
import subprocess
from .media_io import FFMPEG
CANDIDATE = re.compile(r"^\s*V\S*\s+((?:h264|hevc|av1)_(?:nvenc|vaapi|qsv|amf|videotoolbox|v4l2m2m))\s")
RENDER_NODE = "/dev/dri/renderD128"
def _works(name: str) -> bool:
argv = [FFMPEG, "-v", "error", "-nostdin"]
if name.endswith("_vaapi"):
if not os.path.exists(RENDER_NODE):
return False
argv += ["-vaapi_device", RENDER_NODE]
argv += ["-f", "lavfi", "-i", "color=c=black:s=256x256:d=0.1", "-frames:v", "1"]
if name.endswith("_vaapi"):
argv += ["-vf", "format=nv12,hwupload"]
argv += ["-c:v", name, "-f", "null", "-"]
try:
return subprocess.run(argv, capture_output=True, timeout=15, shell=False).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
@functools.cache
def hardware_encoders() -> tuple[str, ...]:
try:
listing = subprocess.run(
[FFMPEG, "-hide_banner", "-encoders"], capture_output=True, timeout=15, shell=False, text=True
).stdout
except (OSError, subprocess.TimeoutExpired):
return ()
names = [m.group(1) for line in listing.splitlines() if (m := CANDIDATE.match(line))]
return tuple(name for name in names if _works(name))

View File

@ -0,0 +1,62 @@
"""Error envelope shared by every route: {"error": {"code", "message", "operation"}}."""
from __future__ import annotations
from fastapi.responses import JSONResponse
STATUS_BY_CODE = {
"bad_request": 400,
"invalid_path": 400,
"unauthorized": 401,
"too_large": 413,
"unsupported": 501,
"busy": 503,
"internal": 500,
}
class MediaError(Exception):
def __init__(self, code: str, message: str, operation: str | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.operation = operation
@property
def status(self) -> int:
return STATUS_BY_CODE[self.code]
class Unsupported(MediaError):
def __init__(self, reason: str, operation: str | None = None) -> None:
super().__init__("unsupported", reason, operation)
class InvalidPath(MediaError):
def __init__(self, message: str = "path is outside MEDIA_ROOT") -> None:
super().__init__("invalid_path", message)
class BadRequest(MediaError):
def __init__(self, message: str) -> None:
super().__init__("bad_request", message)
class Busy(MediaError):
def __init__(self) -> None:
super().__init__("busy", "inference queue is full")
class Cancelled(Exception):
"""The client went away or the request ran past MEDIA_MAX_SECONDS."""
def __init__(self, reason: str = "client disconnected") -> None:
super().__init__(reason)
self.reason = reason
def error_response(code: str, message: str, operation: str | None, status: int | None = None) -> JSONResponse:
return JSONResponse(
status_code=status or STATUS_BY_CODE[code],
content={"error": {"code": code, "message": message, "operation": operation}},
)

View File

@ -0,0 +1,153 @@
"""HTTP API v1 of the Media Studio sidecar.
GET /healthz is open; everything else needs the bearer token. POST
/v1/<operation> takes the TypeScript request object (minus `signal`) and
answers with the TypeScript result object.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import anyio
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from . import VERSION, encoders, schemas
from .auth import require_token
from .config import Settings
from .errors import Cancelled, MediaError, error_response
from .models import OPERATIONS, Registry
from .ops import audio, embeddings, faces, inpaint, redact, saliency, scenes, segment, synthesize, transcribe
from .ops.common import Ctx, jsonable
from .ops.devices import effective_accelerator
from .runtime import Gate, run_job
log = logging.getLogger("media-worker")
MAX_BODY_BYTES = 64 * 1024 * 1024
# Status for a request whose client went away; nobody reads it.
CLIENT_CLOSED = 499
@dataclass(frozen=True)
class Route:
schema: type[BaseModel]
run: Callable[[Ctx, Any, threading.Event], dict[str, Any]]
gpu: bool
ROUTES: dict[str, Route] = {
"detectScenes": Route(schemas.PathRequest, scenes.run, False),
"transcribe": Route(schemas.TranscribeRequest, transcribe.run, True),
"detectFaces": Route(schemas.DetectFacesRequest, faces.detect_run, True),
"trackFaces": Route(schemas.TrackFacesRequest, faces.track_run, True),
"faceLandmarks": Route(schemas.TimesRequest, faces.landmarks_run, True),
"embedFrames": Route(schemas.TimesRequest, embeddings.embed_run, True),
"classifyContent": Route(schemas.ClassifyContentRequest, embeddings.classify_run, True),
"segmentPrompted": Route(schemas.SegmentPromptedRequest, segment.run, True),
"inpaintVideo": Route(schemas.InpaintVideoRequest, inpaint.run, True),
"redact": Route(schemas.RedactRequest, redact.run, False),
"synthesizeIdentity": Route(schemas.SynthesizeIdentityRequest, synthesize.run, True),
"audioFeatures": Route(schemas.PathRequest, audio.run, False),
"saliency": Route(schemas.TimesRequest, saliency.run, False),
}
assert set(ROUTES) == set(OPERATIONS)
def _operation_of(request: Request) -> str | None:
name = request.url.path.rsplit("/", 1)[-1]
return name if name in ROUTES else None
def _validation_message(error: ValidationError) -> str:
first = error.errors()[0] if error.errors() else {}
where = ".".join(str(part) for part in first.get("loc", ()))
return (
f"{where}: {first.get('msg', 'invalid request')}"
if where
else str(first.get("msg", "invalid request"))
)
def create_app(settings: Settings | None = None, registry: Registry | None = None) -> FastAPI:
settings = settings or Settings.from_env()
registry = registry or Registry(settings)
app = FastAPI(title="media-worker", version=VERSION, docs_url=None, redoc_url=None, openapi_url=None)
app.state.settings = settings
app.state.ctx = Ctx(settings, registry)
app.state.gpu_gate = Gate(settings.gpu_concurrency, settings.queue_size)
app.state.cpu_gate = Gate(settings.cpu_concurrency, settings.queue_size)
@app.exception_handler(MediaError)
async def media_error(request: Request, error: MediaError) -> JSONResponse:
return error_response(error.code, error.message, error.operation or _operation_of(request))
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, error: RequestValidationError) -> JSONResponse:
return error_response("bad_request", "invalid request", _operation_of(request))
@app.exception_handler(StarletteHTTPException)
async def http_error(request: Request, error: StarletteHTTPException) -> JSONResponse:
code = "bad_request" if error.status_code < 500 else "internal"
return error_response(code, str(error.detail), _operation_of(request), status=error.status_code)
@app.exception_handler(Exception)
async def unexpected(request: Request, error: Exception) -> JSONResponse:
log.exception("unhandled error")
return error_response("internal", "internal error", _operation_of(request))
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
return {"ok": True, "version": VERSION}
@app.get("/v1/capabilities", dependencies=[Depends(require_token)])
async def capabilities() -> dict[str, Any]:
hardware = await anyio.to_thread.run_sync(encoders.hardware_encoders)
return {
"configured": True,
"operations": registry.operation_capabilities(),
"accelerator": effective_accelerator(settings),
"hardwareEncoders": list(hardware),
}
@app.post("/v1/{operation}", dependencies=[Depends(require_token)])
async def operate(operation: str, request: Request) -> Response:
route = ROUTES.get(operation)
if route is None:
return error_response("bad_request", "unknown operation", None, status=404)
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > MAX_BODY_BYTES:
raise MediaError("too_large", "request body too large", operation)
try:
body = route.schema.model_validate_json(bytes(raw))
except ValidationError as error:
raise MediaError("bad_request", _validation_message(error), operation) from error
ctx: Ctx = request.app.state.ctx
gate: Gate = request.app.state.gpu_gate if route.gpu else request.app.state.cpu_gate
try:
result = await run_job(
request, gate, lambda cancel: route.run(ctx, body, cancel), settings.max_seconds
)
except MediaError as error:
error.operation = operation
raise
except Cancelled as error:
if error.reason == "client disconnected":
return Response(status_code=CLIENT_CLOSED)
raise MediaError("internal", error.reason, operation) from error
except Exception as error:
log.exception("%s failed", operation)
raise MediaError("internal", "internal error", operation) from error
return JSONResponse(jsonable(result))
return app

View File

@ -0,0 +1,435 @@
"""ffmpeg/ffprobe plumbing. Every call is an argv list; nothing goes through a shell."""
from __future__ import annotations
import contextlib
import json
import os
import queue
import re
import subprocess
import threading
import uuid
from collections import deque
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from fractions import Fraction
import numpy as np
from .errors import BadRequest, Cancelled, MediaError
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
SHOWINFO_PTS = re.compile(rb"pts_time:\s*(-?[0-9.]+)")
@dataclass(frozen=True)
class VideoInfo:
width: int
height: int
fps: float
fps_text: str
duration_s: float
has_audio: bool
has_video: bool
start_s: float = 0.0
# Video stream duration when the container reports it (audio may run longer).
video_duration_s: float = 0.0
def frame_ms(self) -> float:
return 1000.0 / self.fps
def run_cancellable(
argv: list[str], cancel: threading.Event, timeout: float | None = None
) -> subprocess.CompletedProcess[bytes]:
"""Run a short-lived tool, killing it when the request is cancelled."""
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False)
killer = _kill_on_cancel(proc, cancel)
try:
stdout, stderr = proc.communicate(timeout=timeout)
finally:
killer.set()
if cancel.is_set():
raise Cancelled()
return subprocess.CompletedProcess(argv, proc.returncode, stdout, stderr)
def _kill_on_cancel(proc: subprocess.Popen[bytes], cancel: threading.Event) -> threading.Event:
done = threading.Event()
def watch() -> None:
while not done.is_set():
if cancel.wait(0.2):
if proc.poll() is None:
proc.kill()
return
threading.Thread(target=watch, daemon=True).start()
return done
def _parse_rate(text: str | None) -> float:
if not text or text in ("0/0", "0"):
return 0.0
try:
return float(Fraction(text))
except (ValueError, ZeroDivisionError):
return 0.0
def probe(path: str, cancel: threading.Event) -> VideoInfo:
result = run_cancellable(
[
FFPROBE,
"-v",
"error",
"-print_format",
"json",
"-show_streams",
"-show_format",
path,
],
cancel,
)
if result.returncode != 0:
raise BadRequest("input is not a readable media file")
data = json.loads(result.stdout or b"{}")
streams = data.get("streams", [])
video = next(
(
s
for s in streams
if s.get("codec_type") == "video" and not s.get("disposition", {}).get("attached_pic")
),
None,
)
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
duration = float(data.get("format", {}).get("duration") or 0.0)
if video is None:
return VideoInfo(0, 0, 0.0, "0", duration, audio is not None, False)
fps_text = video.get("avg_frame_rate") or "0/0"
fps = _parse_rate(fps_text)
if fps <= 0 or fps > 240:
fps_text = video.get("r_frame_rate") or "0/0"
fps = _parse_rate(fps_text)
if fps <= 0:
fps_text, fps = "25", 25.0
width, height = int(video.get("width") or 0), int(video.get("height") or 0)
rotation = _rotation(video)
if rotation in (90, 270):
width, height = height, width
if not duration:
duration = float(video.get("duration") or 0.0)
start = float(video.get("start_time") or 0.0)
video_duration = float(video.get("duration") or 0.0) or duration
return VideoInfo(width, height, fps, fps_text, duration, audio is not None, True, start, video_duration)
def _rotation(stream: dict) -> int:
for side in stream.get("side_data_list", []) or []:
if "rotation" in side:
return int(abs(int(side["rotation"]))) % 360
tag = (stream.get("tags") or {}).get("rotate")
return int(abs(int(tag))) % 360 if tag else 0
def check_duration(info: VideoInfo, max_seconds: int) -> None:
if info.duration_s > max_seconds:
raise MediaError("too_large", "input duration exceeds MEDIA_MAX_SECONDS")
def scaled_size(width: int, height: int, max_side: int | None) -> tuple[int, int]:
if not max_side or max(width, height) <= max_side:
return width - width % 2, height - height % 2
scale = max_side / max(width, height)
w = max(2, int(round(width * scale / 2)) * 2)
h = max(2, int(round(height * scale / 2)) * 2)
return w, h
class _RawReader:
"""Read fixed-size BGR frames from an ffmpeg stdout pipe."""
def __init__(
self,
argv: list[str],
width: int,
height: int,
cancel: threading.Event,
pts: queue.Queue[float] | None = None,
) -> None:
self.size = width * height * 3
self.shape = (height, width, 3)
self.proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False)
self.cancel = cancel
self.pts = pts
self._stderr: deque[bytes] = deque(maxlen=20)
self._drain = threading.Thread(target=self._read_stderr, daemon=True)
self._drain.start()
def _read_stderr(self) -> None:
assert self.proc.stderr is not None
for line in self.proc.stderr:
if self.pts is not None and b"Parsed_showinfo" in line:
match = SHOWINFO_PTS.search(line)
if match:
self.pts.put(float(match.group(1)))
continue
self._stderr.append(line)
def frames(self) -> Iterator[np.ndarray]:
assert self.proc.stdout is not None
try:
while True:
if self.cancel.is_set():
raise Cancelled()
buf = self.proc.stdout.read(self.size)
if not buf:
break
if len(buf) != self.size:
raise MediaError("internal", "truncated frame from decoder")
yield np.frombuffer(buf, np.uint8).reshape(self.shape)
code = self.proc.wait()
self._drain.join(timeout=5)
if code != 0:
raise MediaError("internal", "decoder failed: " + self.stderr_tail())
finally:
self.close()
def stderr_tail(self) -> str:
return b"".join(self._stderr).decode("utf-8", "replace").strip()[-500:]
def close(self) -> None:
if self.proc.poll() is None:
self.proc.kill()
self.proc.wait()
def stream_frames(
path: str,
info: VideoInfo,
cancel: threading.Event,
sample_fps: float | None = None,
max_side: int | None = None,
) -> Iterator[tuple[int, np.ndarray]]:
"""Yield (tMs, frame) for every frame (CFR-normalized) or about `sample_fps` per second.
Sampled frames are real source frames; their timestamps come from
ffmpeg's showinfo, so tMs is exact even for variable frame rates.
"""
width, height = scaled_size(info.width, info.height, max_side)
filters: list[str] = []
if sample_fps:
# The source frame nearest to each k / sample_fps slot (phase-locked, no drift).
half = 0.5 / info.fps
slot = f"floor((t+{half:.6f})*{sample_fps:.6f})"
prev = f"floor((prev_selected_t+{half:.6f})*{sample_fps:.6f})"
filters += [f"select='isnan(prev_selected_t)+gt({slot}\\,{prev})'", "showinfo"]
if (width, height) != (info.width, info.height):
filters.append(f"scale={width}:{height}:flags=area")
level = "info" if sample_fps else "error"
argv = [FFMPEG, "-v", level, "-nostats", "-nostdin", "-i", path, "-map", "0:v:0", "-an", "-sn"]
if filters:
argv += ["-vf", ",".join(filters)]
if sample_fps:
argv += ["-fps_mode", "passthrough"]
else:
argv += ["-fps_mode", "cfr", "-r", info.fps_text]
argv += ["-f", "rawvideo", "-pix_fmt", "bgr24", "pipe:1"]
pts: queue.Queue[float] | None = queue.Queue() if sample_fps else None
reader = _RawReader(argv, width, height, cancel, pts)
for index, frame in enumerate(reader.frames()):
if pts is None:
yield int(round(index * 1000.0 / info.fps)), frame
continue
try:
t = pts.get(timeout=30)
except queue.Empty as error:
raise MediaError("internal", "missing frame timestamp from decoder") from error
yield max(int(round((t - info.start_s) * 1000)), 0), frame
def frame_at(
path: str, info: VideoInfo, t_ms: int, cancel: threading.Event, max_side: int | None = None
) -> np.ndarray:
"""Decode the frame shown at t_ms (accurate seek)."""
width, height = scaled_size(info.width, info.height, max_side)
t = min(max(t_ms / 1000.0, 0.0), max(info.duration_s - 0.5 / info.fps, 0.0))
argv = [
FFMPEG,
"-v",
"error",
"-nostdin",
"-ss",
f"{t:.3f}",
"-i",
path,
"-map",
"0:v:0",
"-frames:v",
"1",
]
if (width, height) != (info.width, info.height):
argv += ["-vf", f"scale={width}:{height}:flags=area"]
argv += ["-f", "rawvideo", "-pix_fmt", "bgr24", "pipe:1"]
frames = list(_RawReader(argv, width, height, cancel).frames())
if not frames:
raise MediaError("internal", f"no frame decoded at {t_ms} ms")
return frames[0]
def frames_at(
path: str, info: VideoInfo, times_ms: list[int], cancel: threading.Event, max_side: int | None = None
) -> Iterator[tuple[int, np.ndarray]]:
for t_ms in times_ms:
if cancel.is_set():
raise Cancelled()
yield t_ms, frame_at(path, info, t_ms, cancel, max_side)
def temp_sibling(final_path: str) -> str:
directory, name = os.path.split(final_path)
stem, ext = os.path.splitext(name)
return os.path.join(directory, f".{stem}.partial-{uuid.uuid4().hex}{ext}")
def encoder_args(out_path: str) -> list[str]:
"""Near-lossless intermediate: FFV1 in Matroska, otherwise x264 CRF 12."""
if out_path.lower().endswith(".mkv"):
return ["-c:v", "ffv1", "-level", "3", "-pix_fmt", "yuv444p", "-g", "1"]
return ["-c:v", "libx264", "-preset", "medium", "-crf", "12", "-pix_fmt", "yuv420p"]
FrameFn = Callable[[int, int, np.ndarray], np.ndarray]
def transform_video(
path: str,
out_path: str,
info: VideoInfo,
cancel: threading.Event,
frame_fn: FrameFn,
) -> int:
"""Decode every frame, apply frame_fn(index, tMs, frame), encode, copy audio.
Fails closed: any decode, transform, or encode error removes the temporary
output and raises; out_path only ever appears complete (atomic rename).
"""
if not info.has_video:
raise BadRequest("input has no video stream")
width, height = scaled_size(info.width, info.height, None)
tmp = temp_sibling(out_path)
enc_argv = [
FFMPEG,
"-v",
"error",
"-nostdin",
"-y",
"-f",
"rawvideo",
"-pix_fmt",
"bgr24",
"-s",
f"{width}x{height}",
"-r",
info.fps_text,
"-i",
"pipe:0",
"-i",
path,
"-map",
"0:v:0",
"-map",
"1:a?",
"-map_metadata",
"-1",
"-map_chapters",
"-1",
*encoder_args(out_path),
"-c:a",
"copy",
tmp,
]
dec_argv = [
FFMPEG,
"-v",
"error",
"-xerror",
"-nostdin",
"-i",
path,
"-map",
"0:v:0",
"-an",
"-sn",
"-fps_mode",
"cfr",
"-r",
info.fps_text,
]
if (width, height) != (info.width, info.height):
dec_argv += ["-vf", f"crop={width}:{height}:0:0"]
dec_argv += ["-f", "rawvideo", "-pix_fmt", "bgr24", "pipe:1"]
encoder = subprocess.Popen(
enc_argv, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, shell=False
)
enc_err: list[bytes] = []
drain = threading.Thread(target=lambda: enc_err.extend(encoder.stderr or []), daemon=True)
drain.start()
reader = _RawReader(dec_argv, width, height, cancel)
count = 0
ok = False
try:
for index, frame in enumerate(reader.frames()):
t_ms = int(round(index * 1000.0 / info.fps))
out = frame_fn(index, t_ms, frame.copy())
if out.shape != frame.shape or out.dtype != np.uint8:
raise MediaError("internal", "frame transform changed the frame shape")
assert encoder.stdin is not None
try:
encoder.stdin.write(np.ascontiguousarray(out).tobytes())
except BrokenPipeError as error:
raise MediaError("internal", "encoder stopped accepting frames") from error
count += 1
expected = (info.video_duration_s or info.duration_s) * info.fps
if count == 0 or (expected > 0 and count < expected * 0.98 - 2):
raise MediaError("internal", f"decoded {count} of ~{int(expected)} frames")
assert encoder.stdin is not None
encoder.stdin.close()
if encoder.wait() != 0:
drain.join(timeout=5)
raise MediaError(
"internal", "encoder failed: " + b"".join(enc_err[-5:]).decode("utf-8", "replace")[-500:]
)
drain.join(timeout=5)
os.replace(tmp, out_path)
ok = True
return count
finally:
reader.close()
if not ok:
if encoder.poll() is None:
encoder.kill()
with contextlib.suppress(subprocess.TimeoutExpired):
encoder.wait(timeout=10)
if os.path.exists(tmp):
os.unlink(tmp)
_NUMBER = r"(-?(?:inf|\d+(?:\.\d+)?))"
def parse_ebur128(stderr: str) -> tuple[float | None, float | None]:
"""Integrated loudness and true peak from the ebur128 summary block."""
summary = stderr[stderr.rfind("Summary:") :] if "Summary:" in stderr else ""
lufs = re.search(r"I:\s+" + _NUMBER + r"\s+LUFS", summary)
peak = re.search(r"Peak:\s+" + _NUMBER + r"\s+dBFS", summary)
def num(match: re.Match[str] | None) -> float | None:
if not match or "inf" in match.group(1):
return None
return float(match.group(1))
return num(lufs), num(peak)

View File

@ -0,0 +1,506 @@
"""Model registry: which backend serves each operation, its license, and its files.
Weights are never part of the image or the repo. They download lazily on first
use into MEDIA_MODELS_DIR/<backend id>/ and are verified against the pinned
SHA256 and size before anything loads them. A mismatch deletes the file and
the operation reports `unsupported` with the reason.
Default backends are commercially usable. Anything else must be listed in
MEDIA_OPTIONAL_BACKENDS by the operator before it runs.
"""
from __future__ import annotations
import hashlib
import importlib.util
import logging
import os
import threading
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from .errors import Unsupported
log = logging.getLogger("media-worker.models")
OPERATIONS = (
"detectScenes",
"transcribe",
"detectFaces",
"trackFaces",
"faceLandmarks",
"embedFrames",
"classifyContent",
"segmentPrompted",
"inpaintVideo",
"redact",
"synthesizeIdentity",
"audioFeatures",
"saliency",
)
HF = "https://huggingface.co"
YUNET_REV = "3cc26e7f1014a5ee5d74a42acee58bafc9d0a310"
SFACE_REV = "3d7082438a6e4551e840c9b2bb60b71e8da4b524"
WHISPER_REV = "536b0662742c02347bc0e980a01041f333bce120"
SIGLIP_REV = "4649052661e53c7000355844105f8a1792088239"
LAMA_REV = "c3c0c9e468934d62e79c329e35d82dd09ff8c444"
SAM2_REV = "de431c4043854a71d8101e17995dfe596bf101a5"
@dataclass(frozen=True)
class ModelFile:
name: str
url: str
sha256: str
sizeBytes: int
@dataclass(frozen=True)
class BackendSpec:
"""One way to serve an operation. `id` doubles as the model cache directory."""
id: str
model: str | None
license: str | None
commercialUse: bool | None
experimental: bool
source: str | None
files: tuple[ModelFile, ...] = ()
# Python modules that must be importable.
requires: tuple[str, ...] = ()
# Set when the backend can never run (e.g. no licensed model exists).
unavailable_reason: str | None = None
@dataclass
class Backend:
spec: BackendSpec
# Builds the runtime object from {file name: verified local path} and the settings.
load: Callable[[dict[str, str], Any], Any] | None = None
def _yunet_file() -> ModelFile:
return ModelFile(
"face_detection_yunet_2023mar.onnx",
f"{HF}/opencv/face_detection_yunet/resolve/{YUNET_REV}/face_detection_yunet_2023mar.onnx",
"8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4",
232589,
)
def _siglip(name: str, sha256: str, size: int) -> ModelFile:
path = name if name == "tokenizer.json" else f"onnx/{name}"
return ModelFile(name, f"{HF}/Xenova/siglip-base-patch16-224/resolve/{SIGLIP_REV}/{path}", sha256, size)
def _whisper(name: str, sha256: str, size: int) -> ModelFile:
return ModelFile(name, f"{HF}/Systran/faster-whisper-small/resolve/{WHISPER_REV}/{name}", sha256, size)
def _lazy(module: str, attr: str) -> Callable[[dict[str, str], Any], Any]:
def load(files: dict[str, str], settings: Any) -> Any:
mod = importlib.import_module(f"app.ops.{module}")
return getattr(mod, attr)(files, settings)
return load
YUNET = BackendSpec(
id="yunet-2023mar",
model="opencv-yunet-2023mar",
license="MIT",
commercialUse=True,
experimental=False,
source="https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet",
files=(_yunet_file(),),
requires=("cv2",),
)
SIGLIP_SOURCE = "https://huggingface.co/google/siglip-base-patch16-224"
SIGLIP_VISION = _siglip(
"vision_model_quantized.onnx",
"ef14a954f3d57e1806666432bd9785004c1dc27100aa260eee0cb0f10a5de058",
99499129,
)
SIGLIP_FULL = _siglip(
"model_quantized.onnx",
"9171eb00c38b9ec82f924877356d008b79e3285dbac7cd10965827bee30c9a99",
210977441,
)
SIGLIP_TOKENIZER = _siglip(
"tokenizer.json",
"4a17c975210be5ab4c36b47d8dae4eefb866dbfb1e676e394aad85dc30a3ae08",
2398744,
)
def default_backends() -> dict[str, Backend]:
return {
"detectScenes": Backend(
BackendSpec(
id="pyscenedetect-adaptive",
model="pyscenedetect-adaptive",
license="BSD-3-Clause",
commercialUse=True,
experimental=False,
source="https://github.com/Breakthrough/PySceneDetect",
requires=("scenedetect", "cv2"),
),
_lazy("scenes", "load_scene_detector"),
),
"transcribe": Backend(
BackendSpec(
id="faster-whisper-small",
model="faster-whisper-small",
license="MIT",
commercialUse=True,
experimental=False,
source="https://huggingface.co/Systran/faster-whisper-small",
files=(
_whisper(
"config.json",
"b55496ac7940a7ae47d2c01eab40edfd8701feec1229d9cce3b40014383fb828",
2370,
),
_whisper(
"model.bin",
"3e305921506d8872816023e4c273e75d2419fb89b24da97b4fe7bce14170d671",
483546902,
),
_whisper(
"tokenizer.json",
"fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab",
2203239,
),
_whisper(
"vocabulary.txt",
"34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913",
459861,
),
),
requires=("faster_whisper",),
),
_lazy("transcribe", "load_transcriber"),
),
"detectFaces": Backend(YUNET, _lazy("faces", "load_face_detector")),
"trackFaces": Backend(
BackendSpec(
id=YUNET.id,
model="opencv-yunet-2023mar+iou-tracker",
license="MIT",
commercialUse=True,
experimental=False,
source=YUNET.source,
files=YUNET.files,
requires=YUNET.requires,
),
_lazy("faces", "load_face_detector"),
),
"faceLandmarks": Backend(
BackendSpec(
id=YUNET.id,
model="opencv-yunet-2023mar-5pt",
license="MIT",
commercialUse=True,
experimental=False,
source=YUNET.source,
files=YUNET.files,
requires=YUNET.requires,
),
_lazy("faces", "load_face_detector"),
),
"embedFrames": Backend(
BackendSpec(
id="siglip-base-patch16-224",
model="siglip-base-patch16-224",
license="Apache-2.0",
commercialUse=True,
experimental=False,
source=SIGLIP_SOURCE,
files=(SIGLIP_VISION,),
requires=("onnxruntime",),
),
_lazy("embeddings", "load_frame_embedder"),
),
"classifyContent": Backend(
BackendSpec(
id="siglip-base-patch16-224",
model="siglip-base-patch16-224-zero-shot",
license="Apache-2.0",
commercialUse=True,
# Zero-shot prompts, not a trained moderation head.
experimental=True,
source=SIGLIP_SOURCE,
files=(SIGLIP_FULL, SIGLIP_TOKENIZER),
requires=("onnxruntime", "tokenizers"),
),
_lazy("embeddings", "load_content_classifier"),
),
"segmentPrompted": Backend(
BackendSpec(
id="sam2.1-hiera-tiny",
model="sam2.1-hiera-tiny",
license="Apache-2.0",
commercialUse=True,
experimental=True,
source="https://github.com/facebookresearch/sam2",
files=(
ModelFile(
"sam2.1_hiera_tiny.pt",
f"{HF}/facebook/sam2.1-hiera-tiny/resolve/{SAM2_REV}/sam2.1_hiera_tiny.pt",
"7402e0d864fa82708a20fbd15bc84245c2f26dff0eb43a4b5b93452deb34be69",
156008466,
),
),
requires=("torch", "sam2"),
),
_lazy("segment", "load_segmenter"),
),
"inpaintVideo": Backend(
BackendSpec(
id="lama-big-onnx",
model="big-lama+dis-flow",
license="Apache-2.0",
commercialUse=True,
experimental=True,
source="https://huggingface.co/Carve/LaMa-ONNX",
files=(
ModelFile(
"lama_fp32.onnx",
f"{HF}/Carve/LaMa-ONNX/resolve/{LAMA_REV}/lama_fp32.onnx",
"1faef5301d78db7dda502fe59966957ec4b79dd64e16f03ed96913c7a4eb68d6",
208044816,
),
),
requires=("onnxruntime", "cv2"),
),
_lazy("inpaint", "load_inpainter"),
),
"redact": Backend(
BackendSpec(
id="opencv-redact",
model="opencv-redact",
license="Apache-2.0",
commercialUse=True,
experimental=False,
source="https://github.com/opencv/opencv",
requires=("cv2",),
),
),
"synthesizeIdentity": Backend(
BackendSpec(
id="synthetic-identity",
model=None,
license=None,
commercialUse=None,
experimental=True,
source=None,
unavailable_reason="no commercially licensed model",
),
),
"audioFeatures": Backend(
BackendSpec(
id="ffmpeg-ebur128-librosa",
model="ffmpeg-ebur128+librosa-beat",
license="ISC",
commercialUse=True,
experimental=False,
source="https://github.com/librosa/librosa",
requires=("librosa",),
),
),
"saliency": Backend(
BackendSpec(
id="spectral-residual",
model="spectral-residual",
license="Apache-2.0",
commercialUse=True,
experimental=False,
source="https://doi.org/10.1109/CVPR.2007.383267",
requires=("cv2",),
),
),
}
# Opt-in backends that are not clean enough to be defaults.
SFACE = Backend(
BackendSpec(
id="sface-2021dec",
model="opencv-sface-2021dec",
license="Apache-2.0 (weights); training data provenance unverified",
commercialUse=None,
experimental=True,
source="https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface",
files=(
ModelFile(
"face_recognition_sface_2021dec.onnx",
f"{HF}/opencv/face_recognition_sface/resolve/{SFACE_REV}/face_recognition_sface_2021dec.onnx",
"0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79",
38696353,
),
),
requires=("cv2",),
),
_lazy("faces", "load_identity_embedder"),
)
OPTIONAL_BACKENDS = {"faceIdentity": SFACE}
def _sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def _module_available(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except (ImportError, ValueError):
return False
@dataclass
class Registry:
settings: Any
backends: dict[str, Backend] = field(default_factory=default_backends)
optional: dict[str, Backend] = field(default_factory=lambda: dict(OPTIONAL_BACKENDS))
_runtimes: dict[str, Any] = field(default_factory=dict)
_verified: set[str] = field(default_factory=set)
_failed: dict[str, str] = field(default_factory=dict)
_lock: threading.Lock = field(default_factory=threading.Lock)
_file_locks: dict[str, threading.Lock] = field(default_factory=dict)
# -- availability -------------------------------------------------------
def _license_block(self, spec: BackendSpec, key: str) -> str | None:
if spec.commercialUse is True:
return None
if key in self.settings.optional_backends or spec.id in self.settings.optional_backends:
return None
return "backend is not licensed for commercial use and not enabled in MEDIA_OPTIONAL_BACKENDS"
def unavailable_reason(self, backend: Backend, key: str) -> str | None:
spec = backend.spec
if spec.unavailable_reason:
return spec.unavailable_reason
blocked = self._license_block(spec, key)
if blocked:
return blocked
missing = [name for name in spec.requires if not _module_available(name)]
if missing:
return f"python module not installed: {', '.join(missing)}"
if spec.id in self._failed:
return self._failed[spec.id]
if not self.settings.allow_model_download:
absent = [f.name for f in spec.files if not os.path.isfile(self._file_path(spec, f))]
if absent:
return "model files not installed and MEDIA_MODEL_DOWNLOAD=0"
return None
def backend(self, operation: str) -> Backend:
return self.backends[operation]
def optional_enabled(self, key: str) -> bool:
backend = self.optional.get(key)
return backend is not None and self.unavailable_reason(backend, key) is None
# -- runtime -------------------------------------------------------------
def runtime(self, operation: str) -> Any:
"""Return the loaded runtime for an operation, downloading weights on first use."""
return self._runtime(self.backends[operation], operation)
def optional_runtime(self, key: str) -> Any:
return self._runtime(self.optional[key], key)
def _runtime(self, backend: Backend, key: str) -> Any:
reason = self.unavailable_reason(backend, key)
if reason:
raise Unsupported(reason)
cache_key = f"{backend.spec.id}:{key}"
with self._lock:
if cache_key in self._runtimes:
return self._runtimes[cache_key]
files = {f.name: self.ensure_file(backend.spec, f) for f in backend.spec.files}
runtime = backend.load(files, self.settings) if backend.load else None
with self._lock:
self._runtimes.setdefault(cache_key, runtime)
return self._runtimes[cache_key]
# -- files ---------------------------------------------------------------
def _file_path(self, spec: BackendSpec, model_file: ModelFile) -> str:
return os.path.join(self.settings.models_dir, spec.id, model_file.name)
def _file_lock(self, path: str) -> threading.Lock:
with self._lock:
return self._file_locks.setdefault(path, threading.Lock())
def ensure_file(self, spec: BackendSpec, model_file: ModelFile) -> str:
path = self._file_path(spec, model_file)
with self._file_lock(path):
if path in self._verified:
return path
if not os.path.isfile(path):
if not self.settings.allow_model_download:
raise Unsupported(f"model file {model_file.name} is not installed")
self._download(model_file, path)
ok = os.path.getsize(path) == model_file.sizeBytes and _sha256(path) == model_file.sha256
if not ok:
os.unlink(path)
reason = f"model file {model_file.name} failed SHA256 verification"
self._failed[spec.id] = reason
raise Unsupported(reason)
self._verified.add(path)
return path
def _download(self, model_file: ModelFile, path: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
partial = f"{path}.part"
log.info("downloading %s", model_file.url)
try:
with (
urllib.request.urlopen(model_file.url, timeout=60) as response,
open(partial, "wb") as handle,
):
written = 0
for chunk in iter(lambda: response.read(1 << 20), b""):
written += len(chunk)
if written > model_file.sizeBytes:
raise ValueError("download larger than pinned size")
handle.write(chunk)
os.replace(partial, path)
except Exception as error:
if os.path.exists(partial):
os.unlink(partial)
raise Unsupported(f"model file {model_file.name} could not be downloaded: {error}") from error
# -- capabilities ----------------------------------------------------------
def operation_capabilities(self) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for operation in OPERATIONS:
backend = self.backends[operation]
spec = backend.spec
result[operation] = {
"available": self.unavailable_reason(backend, operation) is None,
"experimental": spec.experimental,
"model": spec.model,
"license": spec.license,
"commercialUse": spec.commercialUse,
}
return result
def prefetch(self) -> None:
"""Download and verify every default backend's files (operator warm-up)."""
for operation in OPERATIONS:
backend = self.backends[operation]
if self.unavailable_reason(backend, operation) is None:
for model_file in backend.spec.files:
self.ensure_file(backend.spec, model_file)

View File

@ -0,0 +1 @@
"""One module per operation family. Each exposes `run(ctx, body, cancel)`."""

View File

@ -0,0 +1,163 @@
"""audioFeatures without ML: ffmpeg ebur128 + silencedetect, librosa beat tracking.
Audio is decoded once to mono 22.05 kHz float32 and processed in five-minute
chunks, so memory stays bounded and the request can stop between chunks.
"""
from __future__ import annotations
import re
import subprocess
import threading
from typing import Any
import numpy as np
from ..errors import Cancelled, MediaError
from ..media_io import FFMPEG, parse_ebur128, run_cancellable
from ..schemas import PathRequest
from .common import Ctx, open_input
SAMPLE_RATE = 22050
CHUNK_SECONDS = 300
ENERGY_HOP_MS = 250
SILENCE_DB = -40
SILENCE_MIN_S = 0.5
def loudness_and_silence(
path: str, cancel: threading.Event
) -> tuple[float | None, float | None, list[dict[str, int]]]:
result = run_cancellable(
[
FFMPEG,
"-hide_banner",
"-nostats",
"-nostdin",
"-i",
path,
"-map",
"0:a:0",
"-vn",
"-af",
f"ebur128=peak=true,silencedetect=noise={SILENCE_DB}dB:d={SILENCE_MIN_S}",
"-f",
"null",
"-",
],
cancel,
)
stderr = result.stderr.decode("utf-8", "replace")
if result.returncode != 0:
raise MediaError("internal", "loudness measurement failed")
lufs, peak = parse_ebur128(stderr)
silences: list[dict[str, int]] = []
start: float | None = None
for line in stderr.splitlines():
if (m := re.search(r"silence_start:\s*(-?[\d.]+)", line)) is not None:
start = max(float(m.group(1)), 0.0)
elif (m := re.search(r"silence_end:\s*([\d.]+)", line)) is not None and start is not None:
silences.append(
{"startMs": int(round(start * 1000)), "endMs": int(round(float(m.group(1)) * 1000))}
)
start = None
if start is not None:
silences.append({"startMs": int(round(start * 1000)), "endMs": -1})
return lufs, peak, silences
def decode_chunks(path: str, cancel: threading.Event) -> Any:
argv = [
FFMPEG,
"-v",
"error",
"-nostdin",
"-i",
path,
"-map",
"0:a:0",
"-vn",
"-ac",
"1",
"-ar",
str(SAMPLE_RATE),
"-f",
"f32le",
"pipe:1",
]
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=False)
chunk_bytes = SAMPLE_RATE * CHUNK_SECONDS * 4
assert proc.stdout is not None
try:
while True:
if cancel.is_set():
raise Cancelled()
buf = proc.stdout.read(chunk_bytes)
if not buf:
break
usable = len(buf) - len(buf) % 4
yield np.frombuffer(buf[:usable], dtype="<f4").astype(np.float32)
if proc.wait() != 0:
raise MediaError("internal", "audio decode failed")
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
def run(ctx: Ctx, body: PathRequest, cancel: threading.Event) -> dict[str, Any]:
import librosa # deferred: heavy import (numba), only needed here
real, info = open_input(ctx, body.path, cancel, need_video=False)
ctx.registry.runtime("audioFeatures")
if not info.has_audio:
return {
"integratedLufs": None,
"truePeakDb": None,
"tempoBpm": None,
"beatsMs": [],
"energy": [],
"silences": [],
}
lufs, peak, silences = loudness_and_silence(real, cancel)
hop = int(SAMPLE_RATE * ENERGY_HOP_MS / 1000)
beats: list[int] = []
tempos: list[tuple[float, float]] = []
rms_values: list[float] = []
offset_s = 0.0
for samples in decode_chunks(real, cancel):
length_s = samples.size / SAMPLE_RATE
if samples.size >= SAMPLE_RATE * 2 and float(np.abs(samples).max()) > 1e-4:
tempo, frames = librosa.beat.beat_track(y=samples, sr=SAMPLE_RATE, units="frames")
times = librosa.frames_to_time(frames, sr=SAMPLE_RATE)
beats.extend(int(round((offset_s + t) * 1000)) for t in times)
value = float(np.atleast_1d(tempo)[0])
if value > 0:
tempos.append((value, length_s))
for i in range(0, samples.size, hop):
window = samples[i : i + hop]
rms_values.append(float(np.sqrt(np.mean(window.astype(np.float64) ** 2))) if window.size else 0.0)
offset_s += length_s
duration_ms = int(round(offset_s * 1000))
for silence in silences:
if silence["endMs"] < 0:
silence["endMs"] = duration_ms
peak_rms = max(rms_values) if rms_values else 0.0
energy = [
{"tMs": i * ENERGY_HOP_MS, "value": (v / peak_rms) if peak_rms > 0 else 0.0}
for i, v in enumerate(rms_values)
]
tempo_bpm = None
if tempos:
weights = np.array([w for _, w in tempos])
tempo_bpm = float(np.average([t for t, _ in tempos], weights=weights))
return {
"integratedLufs": lufs,
"truePeakDb": peak,
"tempoBpm": tempo_bpm,
"beatsMs": sorted(set(beats)),
"energy": energy,
"silences": silences,
}

View File

@ -0,0 +1,62 @@
"""Helpers shared by the operation modules."""
from __future__ import annotations
import threading
from dataclasses import dataclass
from typing import Any
import numpy as np
from .. import media_io, paths
from ..config import Settings
from ..errors import BadRequest
from ..media_io import VideoInfo
from ..models import Registry
@dataclass
class Ctx:
settings: Settings
registry: Registry
def open_input(
ctx: Ctx, path: str, cancel: threading.Event, need_video: bool = True
) -> tuple[str, VideoInfo]:
"""Resolve, size-check, probe, and duration-check an input file."""
real = paths.input_file(ctx.settings.media_root, path, ctx.settings.max_input_bytes)
info = media_io.probe(real, cancel)
media_io.check_duration(info, ctx.settings.max_seconds)
if need_video and not info.has_video:
raise BadRequest("input has no video stream")
return real, info
def clamp01(value: float) -> float:
return float(min(max(value, 0.0), 1.0))
def norm_box(x: float, y: float, w: float, h: float, width: int, height: int) -> dict[str, float]:
"""Pixel box -> normalized box clipped to the frame."""
x0, y0 = clamp01(x / width), clamp01(y / height)
x1, y1 = clamp01((x + w) / width), clamp01((y + h) / height)
return {"x": x0, "y": y0, "w": max(x1 - x0, 0.0), "h": max(y1 - y0, 0.0)}
def norm_point(x: float, y: float, width: int, height: int) -> dict[str, float]:
return {"x": clamp01(x / width), "y": clamp01(y / height)}
def unique_sorted(times: list[int]) -> list[int]:
return sorted(set(int(t) for t in times))
def jsonable(value: Any) -> Any:
if isinstance(value, dict):
return {k: jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [jsonable(v) for v in value]
if isinstance(value, np.generic):
return value.item()
return value

View File

@ -0,0 +1,61 @@
"""Accelerator detection. ML libraries are imported only when installed."""
from __future__ import annotations
import functools
import importlib.util
from typing import Any
def _installed(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except (ImportError, ValueError):
return False
@functools.cache
def torch_gpu_available() -> bool:
if not _installed("torch"):
return False
import torch
return bool(torch.cuda.is_available())
def effective_accelerator(settings: Any) -> str:
"""What the image was built for, downgraded to cpu when no GPU is visible.
ROCm builds of torch expose the GPU through the torch.cuda API.
"""
if settings.accelerator in ("cuda", "rocm") and torch_gpu_available():
return settings.accelerator
return "cpu"
def torch_device(settings: Any) -> str:
return "cuda" if effective_accelerator(settings) != "cpu" else "cpu"
def ctranslate2_device(settings: Any) -> str:
# CTranslate2 ships CUDA builds only; ROCm hosts transcribe on the CPU.
if settings.accelerator != "cuda":
return "cpu"
import ctranslate2
return "cuda" if ctranslate2.get_cuda_device_count() > 0 else "cpu"
def onnx_providers(settings: Any) -> list[str]:
import onnxruntime
available = set(onnxruntime.get_available_providers())
providers: list[str] = []
if settings.accelerator == "cuda" and "CUDAExecutionProvider" in available:
providers.append("CUDAExecutionProvider")
if settings.accelerator == "rocm":
for name in ("MIGraphXExecutionProvider", "ROCMExecutionProvider"):
if name in available:
providers.append(name)
providers.append("CPUExecutionProvider")
return providers

View File

@ -0,0 +1,147 @@
"""embedFrames and classifyContent with SigLIP (Apache-2.0) on ONNX Runtime.
classifyContent is zero-shot: each category is a set of text prompts and the
score is the highest sigmoid probability among them. It is reported as
experimental; the gate thresholds decide what the scores mean.
"""
from __future__ import annotations
import threading
from typing import Any
import cv2
import numpy as np
from .. import media_io
from ..schemas import ClassifyContentRequest, TimesRequest
from .common import Ctx, clamp01, open_input, unique_sorted
from .devices import onnx_providers
SIGLIP_SIZE = 224
SIGLIP_TEXT_LENGTH = 64
SIGLIP_PAD_ID = 1
BATCH = 16
CATEGORY_PROMPTS: dict[str, tuple[str, ...]] = {
"explicit_nudity": ("a photo of a naked person", "a photo containing explicit nudity"),
"suggestive": ("a photo of a person in lingerie", "a sexually suggestive photo"),
"violence": ("a photo of a violent fight", "a photo showing blood and injuries"),
"minors_risk": ("a photo of a child", "a photo of a young teenager"),
"self_harm": ("a photo of self-harm", "a photo of cuts on a wrist"),
"drugs": ("a photo of illegal drugs", "a photo of drug paraphernalia"),
"weapons": ("a photo of a gun", "a photo of a person holding a knife as a weapon"),
}
def preprocess(frames: list[np.ndarray]) -> np.ndarray:
"""BGR frames -> SigLIP pixel_values (N, 3, 224, 224), normalized to [-1, 1]."""
batch = []
for frame in frames:
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
resized = cv2.resize(rgb, (SIGLIP_SIZE, SIGLIP_SIZE), interpolation=cv2.INTER_CUBIC)
batch.append(resized.astype(np.float32) / 127.5 - 1.0)
return np.ascontiguousarray(np.stack(batch).transpose(0, 3, 1, 2))
def _session(path: str, settings: Any) -> Any:
import onnxruntime
return onnxruntime.InferenceSession(path, providers=onnx_providers(settings))
class SiglipEmbedder:
model = "siglip-base-patch16-224"
def __init__(self, path: str, settings: Any) -> None:
self.session = _session(path, settings)
names = [o.name for o in self.session.get_outputs()]
self.output = "pooler_output" if "pooler_output" in names else names[-1]
self.dim = 768
def embed(self, frames: list[np.ndarray]) -> np.ndarray:
(vectors,) = self.session.run([self.output], {"pixel_values": preprocess(frames)})
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / np.maximum(norms, 1e-12)
def load_frame_embedder(files: dict[str, str], settings: Any) -> SiglipEmbedder:
return SiglipEmbedder(files["vision_model_quantized.onnx"], settings)
class SiglipZeroShot:
model = "siglip-base-patch16-224-zero-shot"
def __init__(self, model_path: str, tokenizer_path: str, settings: Any) -> None:
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_file(tokenizer_path)
tokenizer.enable_truncation(SIGLIP_TEXT_LENGTH)
tokenizer.enable_padding(length=SIGLIP_TEXT_LENGTH, pad_id=SIGLIP_PAD_ID)
self.labels: list[str] = []
prompts: list[str] = []
for category, texts in CATEGORY_PROMPTS.items():
for text in texts:
self.labels.append(category)
prompts.append(text.lower())
self.input_ids = np.array([e.ids for e in tokenizer.encode_batch(prompts)], dtype=np.int64)
self.session = _session(model_path, settings)
def classify(self, frames: list[np.ndarray]) -> list[dict[str, float]]:
(logits,) = self.session.run(
["logits_per_image"], {"input_ids": self.input_ids, "pixel_values": preprocess(frames)}
)
probs = 1.0 / (1.0 + np.exp(-logits))
result = []
for row in probs:
scores: dict[str, float] = {}
for label, value in zip(self.labels, row, strict=True):
scores[label] = max(scores.get(label, 0.0), clamp01(float(value)))
result.append(scores)
return result
def load_content_classifier(files: dict[str, str], settings: Any) -> SiglipZeroShot:
return SiglipZeroShot(files["model_quantized.onnx"], files["tokenizer.json"], settings)
def _batched(frames: Any, size: int) -> Any:
times: list[int] = []
batch: list[np.ndarray] = []
for t_ms, frame in frames:
times.append(t_ms)
batch.append(frame)
if len(batch) == size:
yield times, batch
times, batch = [], []
if batch:
yield times, batch
def embed_run(ctx: Ctx, body: TimesRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
embedder = ctx.registry.runtime("embedFrames")
vectors: list[list[float]] = []
# Vectors follow the request order, duplicates included.
for _, batch in _batched(media_io.frames_at(real, info, list(body.timesMs), cancel, 512), BATCH):
vectors.extend([float(v) for v in row] for row in embedder.embed(batch))
return {"model": embedder.model, "dim": int(embedder.dim), "vectors": vectors}
def classify_run(ctx: Ctx, body: ClassifyContentRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
classifier = ctx.registry.runtime("classifyContent")
results: dict[int, dict[str, float]] = {}
def score(frames: Any) -> None:
for times, batch in _batched(frames, BATCH):
for t_ms, scores in zip(times, classifier.classify(batch), strict=True):
results[t_ms] = scores
score(media_io.stream_frames(real, info, cancel, body.sampleFps, 512))
extra = [t for t in unique_sorted(body.extraTimesMs or []) if t not in results]
score(media_io.frames_at(real, info, extra, cancel, 512))
return {
"model": classifier.model,
"frames": [{"tMs": t, "scores": results[t]} for t in sorted(results)],
}

View File

@ -0,0 +1,265 @@
"""detectFaces, trackFaces, faceLandmarks.
Detection is OpenCV YuNet (MIT). Tracking is a ByteTrack-style IoU tracker
implemented here: high-confidence detections associate first, low-confidence
ones only extend existing tracks, and a track ends after 500 ms without a match.
Identity embeddings (for local person clustering) come from an opt-in backend
and are null otherwise.
"""
from __future__ import annotations
import math
import threading
from dataclasses import dataclass, field
from typing import Any
import cv2
import numpy as np
from .. import media_io
from ..schemas import DetectFacesRequest, TimesRequest, TrackFacesRequest
from .common import Ctx, clamp01, norm_box, norm_point, open_input, unique_sorted
DETECT_MAX_SIDE = 1280
DETECT_THRESHOLD = 0.6
TRACK_LOW_THRESHOLD = 0.3
TRACK_MATCH_IOU = 0.3
TRACK_MAX_GAP_MS = 500
@dataclass
class Detection:
"""Pixel-space face: box (x, y, w, h), score, five landmarks (x, y)."""
box: tuple[float, float, float, float]
score: float
landmarks: list[tuple[float, float]]
class YuNetDetector:
def __init__(self, model_path: str) -> None:
self.model_path = model_path
self._local = threading.local()
def _detector(self, width: int, height: int) -> Any:
detector = getattr(self._local, "detector", None)
if detector is None:
detector = cv2.FaceDetectorYN.create(
self.model_path, "", (width, height), TRACK_LOW_THRESHOLD, 0.3, 5000
)
self._local.detector = detector
detector.setInputSize((width, height))
return detector
def detect(self, frame: np.ndarray) -> list[Detection]:
height, width = frame.shape[:2]
_, faces = self._detector(width, height).detect(frame)
result: list[Detection] = []
for row in faces if faces is not None else []:
points = [(float(row[4 + 2 * i]), float(row[5 + 2 * i])) for i in range(5)]
result.append(
Detection(
(float(row[0]), float(row[1]), float(row[2]), float(row[3])), float(row[14]), points
)
)
return result
def load_face_detector(files: dict[str, str], _settings: Any) -> YuNetDetector:
return YuNetDetector(files["face_detection_yunet_2023mar.onnx"])
class SFaceEmbedder:
def __init__(self, model_path: str) -> None:
self.model_path = model_path
self._local = threading.local()
def embed(self, frame: np.ndarray, detection: Detection) -> np.ndarray:
recognizer = getattr(self._local, "recognizer", None)
if recognizer is None:
recognizer = cv2.FaceRecognizerSF.create(self.model_path, "")
self._local.recognizer = recognizer
row = np.array(
[*detection.box, *[c for p in detection.landmarks for c in p], detection.score],
dtype=np.float32,
)
aligned = recognizer.alignCrop(frame, row)
return recognizer.feature(aligned).reshape(-1).astype(np.float32)
def load_identity_embedder(files: dict[str, str], _settings: Any) -> SFaceEmbedder:
return SFaceEmbedder(files["face_recognition_sface_2021dec.onnx"])
def _observation(det: Detection, width: int, height: int) -> dict[str, Any]:
return {
"box": norm_box(*det.box, width, height),
"confidence": clamp01(det.score),
"landmarks": [norm_point(x, y, width, height) for x, y in det.landmarks],
}
def detect_run(ctx: Ctx, body: DetectFacesRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
detector = ctx.registry.runtime("detectFaces")
frames = (
media_io.frames_at(real, info, unique_sorted(body.timesMs), cancel, DETECT_MAX_SIDE)
if body.timesMs is not None
else media_io.stream_frames(real, info, cancel, body.sampleFps, DETECT_MAX_SIDE)
)
out = []
for t_ms, frame in frames:
height, width = frame.shape[:2]
faces = [
_observation(d, width, height) for d in detector.detect(frame) if d.score >= DETECT_THRESHOLD
]
out.append({"tMs": t_ms, "faces": faces})
return {"frames": out}
def landmarks_run(ctx: Ctx, body: TimesRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
detector = ctx.registry.runtime("faceLandmarks")
out = []
for t_ms, frame in media_io.frames_at(real, info, unique_sorted(body.timesMs), cancel, DETECT_MAX_SIDE):
height, width = frame.shape[:2]
faces = [
{
"box": norm_box(*d.box, width, height),
"landmarks": [norm_point(x, y, width, height) for x, y in d.landmarks],
}
for d in detector.detect(frame)
if d.score >= DETECT_THRESHOLD
]
out.append({"tMs": t_ms, "faces": faces})
return {"frames": out}
def iou(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
ax1, ay1, ax2, ay2 = a[0], a[1], a[0] + a[2], a[1] + a[3]
bx1, by1, bx2, by2 = b[0], b[1], b[0] + b[2], b[1] + b[3]
iw = max(0.0, min(ax2, bx2) - max(ax1, bx1))
ih = max(0.0, min(ay2, by2) - max(ay1, by1))
inter = iw * ih
union = a[2] * a[3] + b[2] * b[3] - inter
return inter / union if union > 0 else 0.0
@dataclass
class Track:
track_id: str
box: tuple[float, float, float, float]
velocity: tuple[float, float] = (0.0, 0.0)
last_ms: int = 0
keyframes: list[dict[str, Any]] = field(default_factory=list)
# Best (score, frame crop source, detection) for identity embeddings.
best: list[tuple[float, np.ndarray, Detection]] = field(default_factory=list)
def predicted(self, t_ms: int) -> tuple[float, float, float, float]:
dt = t_ms - self.last_ms
return (
self.box[0] + self.velocity[0] * dt,
self.box[1] + self.velocity[1] * dt,
self.box[2],
self.box[3],
)
class IouTracker:
"""Two-stage greedy IoU association (ByteTrack's idea without the Kalman filter)."""
def __init__(self, max_gap_ms: int = TRACK_MAX_GAP_MS) -> None:
self.max_gap_ms = max_gap_ms
self.active: list[Track] = []
self.finished: list[Track] = []
self._next = 1
def _associate(
self, tracks: list[Track], dets: list[Detection], t_ms: int
) -> tuple[list[tuple[Track, Detection]], list[Track], list[Detection]]:
pairs = sorted(
(
(iou(tr.predicted(t_ms), d.box), ti, di)
for ti, tr in enumerate(tracks)
for di, d in enumerate(dets)
),
reverse=True,
)
used_t: set[int] = set()
used_d: set[int] = set()
matches: list[tuple[Track, Detection]] = []
for score, ti, di in pairs:
if score < TRACK_MATCH_IOU:
break
if ti in used_t or di in used_d:
continue
used_t.add(ti)
used_d.add(di)
matches.append((tracks[ti], dets[di]))
rest_t = [t for i, t in enumerate(tracks) if i not in used_t]
rest_d = [d for i, d in enumerate(dets) if i not in used_d]
return matches, rest_t, rest_d
def update(
self, t_ms: int, dets: list[Detection], frame: np.ndarray | None, keep_crops: bool
) -> list[tuple[Track, Detection]]:
expired = [t for t in self.active if t_ms - t.last_ms > self.max_gap_ms]
self.finished.extend(expired)
self.active = [t for t in self.active if t not in expired]
high = [d for d in dets if d.score >= DETECT_THRESHOLD]
low = [d for d in dets if TRACK_LOW_THRESHOLD <= d.score < DETECT_THRESHOLD]
matches, rest_tracks, rest_high = self._associate(self.active, high, t_ms)
low_matches, _, _ = self._associate(rest_tracks, low, t_ms)
matches += low_matches
for track, det in matches:
dt = max(t_ms - track.last_ms, 1)
track.velocity = ((det.box[0] - track.box[0]) / dt, (det.box[1] - track.box[1]) / dt)
track.box = det.box
track.last_ms = t_ms
for det in rest_high:
track = Track(f"t{self._next}", det.box, last_ms=t_ms)
self._next += 1
self.active.append(track)
matches.append((track, det))
if keep_crops and frame is not None:
for track, det in matches:
if det.score >= DETECT_THRESHOLD:
track.best.append((det.score, frame, det))
track.best.sort(key=lambda item: -item[0])
del track.best[3:]
return matches
def close(self) -> list[Track]:
tracks = self.finished + self.active
self.active, self.finished = [], []
return sorted(tracks, key=lambda t: int(t.track_id[1:]))
def track_run(ctx: Ctx, body: TrackFacesRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
detector = ctx.registry.runtime("trackFaces")
embedder = (
ctx.registry.optional_runtime("faceIdentity")
if ctx.registry.optional_enabled("faceIdentity")
else None
)
tracker = IouTracker()
for t_ms, frame in media_io.stream_frames(real, info, cancel, body.sampleFps, DETECT_MAX_SIDE):
height, width = frame.shape[:2]
matches = tracker.update(t_ms, detector.detect(frame), frame, embedder is not None)
for track, det in matches:
track.keyframes.append(
{"tMs": t_ms, "box": norm_box(*det.box, width, height), "confidence": clamp01(det.score)}
)
tracks = []
for track in tracker.close():
embedding = None
if embedder is not None and track.best:
vectors = np.stack([embedder.embed(frame, det) for _, frame, det in track.best])
mean = vectors.mean(axis=0)
norm = float(np.linalg.norm(mean))
if norm > 0 and math.isfinite(norm):
embedding = [float(v) for v in mean / norm]
tracks.append({"trackId": track.track_id, "keyframes": track.keyframes, "embedding": embedding})
return {"sampleFps": body.sampleFps, "tracks": tracks}

View File

@ -0,0 +1,163 @@
"""inpaintVideo: LaMa (Apache-2.0, ONNX) per frame with optical-flow smoothing.
Per frame: crop around the mask, inpaint at 512x512, paste back through a
feathered mask. When the previous frame was inpainted too, its result is
warped into the current frame with DIS optical flow and blended in, which
damps flicker. Quality is measured on the output:
- temporalStability: 1 - mean absolute change inside the mask between
consecutive frames after motion compensation (scaled so 32 levels -> 0).
- edgeQuality: 1 - excess gradient on the mask boundary over the gradient
just outside it.
- score: 0.6 * temporalStability + 0.4 * edgeQuality.
Callers fall back to cover/reframe when the score is low.
"""
from __future__ import annotations
import os
import threading
from typing import Any
import cv2
import numpy as np
from .. import media_io, paths
from ..errors import BadRequest
from ..schemas import InpaintVideoRequest
from .common import Ctx, clamp01, open_input
from .devices import onnx_providers
from .masks import MaskIndex
from .redact import OUTPUT_EXTENSIONS
LAMA_SIZE = 512
TEMPORAL_BLEND = 0.35
class LamaInpainter:
def __init__(self, path: str, settings: Any) -> None:
import onnxruntime
self.session = onnxruntime.InferenceSession(path, providers=onnx_providers(settings))
def inpaint(self, image: np.ndarray, mask: np.ndarray) -> np.ndarray:
"""image: BGR uint8 512x512, mask: uint8 0/255 512x512 -> BGR uint8."""
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
pixel = rgb.transpose(2, 0, 1)[None]
m = (mask > 127).astype(np.float32)[None, None]
(out,) = self.session.run(["output"], {"image": pixel, "mask": m})
result = np.clip(out[0].transpose(1, 2, 0), 0, 255).astype(np.uint8)
return cv2.cvtColor(result, cv2.COLOR_RGB2BGR)
def load_inpainter(files: dict[str, str], settings: Any) -> LamaInpainter:
return LamaInpainter(files["lama_fp32.onnx"], settings)
def _crop_box(mask: np.ndarray) -> tuple[int, int, int, int]:
height, width = mask.shape
ys, xs = np.nonzero(mask)
cx, cy = (xs.min() + xs.max()) / 2, (ys.min() + ys.max()) / 2
side = max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1) * 2.2
side = int(min(max(side, 256), max(width, height)))
x0 = int(min(max(cx - side / 2, 0), max(width - side, 0)))
y0 = int(min(max(cy - side / 2, 0), max(height - side, 0)))
return x0, y0, min(x0 + side, width), min(y0 + side, height)
class Smoother:
"""Temporal blending and quality bookkeeping across frames."""
def __init__(self) -> None:
self.flow = cv2.DISOpticalFlow_create(cv2.DISOPTICAL_FLOW_PRESET_FAST)
self.prev_gray: np.ndarray | None = None
self.prev_out: np.ndarray | None = None
self.prev_masked = False
self.stability: list[float] = []
self.edges: list[float] = []
def _warp_prev(self, gray: np.ndarray) -> np.ndarray | None:
if self.prev_gray is None or self.prev_out is None:
return None
flow = self.flow.calc(gray, self.prev_gray, None)
h, w = gray.shape
grid_x, grid_y = np.meshgrid(np.arange(w, dtype=np.float32), np.arange(h, dtype=np.float32))
return cv2.remap(
self.prev_out,
grid_x + flow[..., 0],
grid_y + flow[..., 1],
cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
def step(self, source: np.ndarray, inpainted: np.ndarray | None, mask: np.ndarray | None) -> np.ndarray:
gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)
out = source
if inpainted is not None and mask is not None:
soft = cv2.GaussianBlur(mask.astype(np.float32) / 255.0, (0, 0), 3)
soft = np.maximum(soft, mask.astype(np.float32) / 255.0)[..., None]
warped = self._warp_prev(gray) if self.prev_masked else None
fill = inpainted.astype(np.float32)
if warped is not None:
fill = fill * (1 - TEMPORAL_BLEND) + warped.astype(np.float32) * TEMPORAL_BLEND
out = np.clip(source.astype(np.float32) * (1 - soft) + fill * soft, 0, 255).astype(np.uint8)
inside = mask > 0
if warped is not None:
# Raw LaMa output against the motion-compensated previous frame: flicker before smoothing.
diff = np.abs(inpainted.astype(np.float32) - warped.astype(np.float32))[inside].mean()
self.stability.append(clamp01(1.0 - float(diff) / 32.0))
self.edges.append(_edge_quality(out, mask))
self.prev_gray = gray
self.prev_out = out
self.prev_masked = mask is not None
return out
def quality(self) -> dict[str, float]:
temporal = float(np.mean(self.stability)) if self.stability else 1.0
edge = float(np.mean(self.edges)) if self.edges else 1.0
return {
"temporalStability": clamp01(temporal),
"edgeQuality": clamp01(edge),
"score": clamp01(0.6 * temporal + 0.4 * edge),
}
def _edge_quality(frame: np.ndarray, mask: np.ndarray) -> float:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).astype(np.float32)
grad = cv2.magnitude(cv2.Sobel(gray, cv2.CV_32F, 1, 0), cv2.Sobel(gray, cv2.CV_32F, 0, 1))
kernel = np.ones((5, 5), np.uint8)
boundary = (cv2.dilate(mask, kernel) > 0) & (cv2.erode(mask, kernel) == 0)
outer = (cv2.dilate(mask, np.ones((15, 15), np.uint8)) > 0) & ~(cv2.dilate(mask, kernel) > 0)
if not boundary.any() or not outer.any():
return 1.0
excess = max(0.0, float(grad[boundary].mean()) - float(grad[outer].mean()))
return clamp01(1.0 - excess / (float(grad[outer].mean()) + 40.0))
def run(ctx: Ctx, body: InpaintVideoRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
masks = MaskIndex(paths.input_dir(ctx.settings.media_root, body.maskDir), info.frame_ms())
out_real = paths.output_file(ctx.settings.media_root, body.outPath)
if os.path.splitext(out_real)[1].lower() not in OUTPUT_EXTENSIONS:
raise BadRequest("outPath must end in .mkv, .mp4 or .mov")
if out_real == real:
raise BadRequest("outPath must differ from path")
inpainter = ctx.registry.runtime("inpaintVideo")
smoother = Smoother()
def per_frame(_index: int, t_ms: int, frame: np.ndarray) -> np.ndarray:
height, width = frame.shape[:2]
mask = masks.lookup(t_ms, width, height)
if mask is None or not mask.any():
return smoother.step(frame, None, None)
# Grow slightly so the seam lands on untouched pixels.
mask = cv2.dilate(mask, np.ones((7, 7), np.uint8))
x0, y0, x1, y1 = _crop_box(mask)
crop = cv2.resize(frame[y0:y1, x0:x1], (LAMA_SIZE, LAMA_SIZE), interpolation=cv2.INTER_AREA)
crop_mask = cv2.resize(mask[y0:y1, x0:x1], (LAMA_SIZE, LAMA_SIZE), interpolation=cv2.INTER_NEAREST)
filled = inpainter.inpaint(crop, crop_mask)
full = frame.copy()
full[y0:y1, x0:x1] = cv2.resize(filled, (x1 - x0, y1 - y0), interpolation=cv2.INTER_CUBIC)
return smoother.step(frame, full, mask)
media_io.transform_video(real, out_real, info, cancel, per_frame)
return {"outPath": body.outPath, "quality": smoother.quality()}

View File

@ -0,0 +1,54 @@
"""Per-frame mask directories (`<tMs>.png`), as written by segmentPrompted."""
from __future__ import annotations
import bisect
import os
import re
import cv2
import numpy as np
from ..errors import BadRequest, MediaError
MASK_NAME = re.compile(r"^(\d+)\.png$")
class MaskIndex:
def __init__(self, directory: str, frame_ms: float) -> None:
entries = []
for name in os.listdir(directory):
match = MASK_NAME.match(name)
if match:
entries.append((int(match.group(1)), os.path.join(directory, name)))
if not entries:
raise BadRequest("mask directory contains no <tMs>.png masks")
entries.sort()
self.times = [t for t, _ in entries]
self.files = [p for _, p in entries]
gaps = np.diff(self.times) if len(self.times) > 1 else np.array([frame_ms])
# A mask covers frames up to 1.5 sampling intervals away (at least one frame).
self.tolerance = max(float(np.median(gaps)) * 1.5, frame_ms)
self._cache: tuple[int, np.ndarray] | None = None
def lookup(self, t_ms: int, width: int, height: int) -> np.ndarray | None:
"""Binary mask (uint8 0/255) for the frame at t_ms, resized to the frame, or None."""
i = bisect.bisect_left(self.times, t_ms)
best = None
for j in (i - 1, i):
if not 0 <= j < len(self.times) or abs(self.times[j] - t_ms) > self.tolerance:
continue
if best is None or abs(self.times[j] - t_ms) < abs(self.times[best] - t_ms):
best = j
if best is None:
return None
if self._cache is not None and self._cache[0] == best:
return self._cache[1]
mask = cv2.imread(self.files[best], cv2.IMREAD_GRAYSCALE)
if mask is None:
raise MediaError("internal", f"unreadable mask {os.path.basename(self.files[best])}")
if mask.shape != (height, width):
mask = cv2.resize(mask, (width, height), interpolation=cv2.INTER_NEAREST)
mask = np.where(mask > 127, 255, 0).astype(np.uint8)
self._cache = (best, mask)
return mask

View File

@ -0,0 +1,271 @@
"""redact: frame-accurate face and region redaction (OpenCV + ffmpeg pipes).
Geometry per instruction and frame:
- Boxes interpolate linearly between keyframes. A single keyframe is a static
region for the whole video; otherwise the region applies from the first to
the last keyframe (plus half a frame either side).
- `paddingPct` grows the box by that percentage of its width/height (half on
each side). Mask regions are dilated by the same share of their extent.
- `featherPct` fades the effect outward over that percentage of the region's
shorter side. The region itself is always fully covered.
Fails closed: an unreadable frame, a mask that cannot be read, or an encoder
error is a 500 and no file is left at outPath.
"""
from __future__ import annotations
import bisect
import os
import threading
from dataclasses import dataclass
from typing import Any
import cv2
import numpy as np
from .. import media_io, paths
from ..errors import BadRequest
from ..schemas import RedactionInstruction, RedactRequest
from .common import Ctx, open_input
from .masks import MaskIndex
OUTPUT_EXTENSIONS = (".mkv", ".mp4", ".mov")
DEFAULT_FILL = (0, 0, 0)
def hex_to_bgr(color: str | None) -> tuple[int, int, int] | None:
if not color:
return None
value = color.lstrip("#")
r, g, b = int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
return (b, g, r)
@dataclass
class Plan:
mode: str
padding: float
feather: float
color: tuple[int, int, int] | None
times: list[int]
boxes: list[tuple[float, float, float, float]]
landmark_times: list[int]
landmarks: list[list[tuple[float, float]]]
masks: MaskIndex | None
def build_plan(ctx: Ctx, instruction: RedactionInstruction, frame_ms: float) -> Plan:
keyframes = sorted(instruction.keyframes, key=lambda k: k.tMs)
landmarks = sorted(instruction.landmarks or [], key=lambda frame: frame.tMs)
masks = None
if instruction.maskDir:
masks = MaskIndex(paths.input_dir(ctx.settings.media_root, instruction.maskDir), frame_ms)
return Plan(
mode=instruction.mode,
padding=instruction.paddingPct / 100.0,
feather=instruction.featherPct / 100.0,
color=hex_to_bgr(instruction.color),
times=[k.tMs for k in keyframes],
boxes=[(k.box.x, k.box.y, k.box.w, k.box.h) for k in keyframes],
landmark_times=[frame.tMs for frame in landmarks],
landmarks=[[(p.x, p.y) for p in frame.points] for frame in landmarks],
masks=masks,
)
def interpolate(times: list[int], values: list[Any], t_ms: float, frame_ms: float, hold_single: bool) -> Any:
"""Linear interpolation of tuples/lists over keyframe times; None outside the span."""
if not times:
return None
if len(times) == 1:
if hold_single or abs(t_ms - times[0]) <= frame_ms / 2:
return values[0]
return None
if t_ms < times[0] - frame_ms / 2 or t_ms > times[-1] + frame_ms / 2:
return None
if t_ms <= times[0]:
return values[0]
if t_ms >= times[-1]:
return values[-1]
i = bisect.bisect_right(times, t_ms)
t0, t1 = times[i - 1], times[i]
a, b = values[i - 1], values[i]
if t1 == t0:
return b
f = (t_ms - t0) / (t1 - t0)
if isinstance(a, list):
if len(a) != len(b):
return a if f < 0.5 else b
return [tuple(pa[k] + (pb[k] - pa[k]) * f for k in range(2)) for pa, pb in zip(a, b, strict=True)]
return tuple(a[k] + (b[k] - a[k]) * f for k in range(len(a)))
def _pixel_box(
box: tuple[float, float, float, float], padding: float, width: int, height: int
) -> tuple[int, int, int, int]:
x, y, w, h = box
x -= w * padding / 2
y -= h * padding / 2
w *= 1 + padding
h *= 1 + padding
x0 = int(np.floor(max(0.0, x) * width))
y0 = int(np.floor(max(0.0, y) * height))
x1 = int(np.ceil(min(1.0, x + w) * width))
y1 = int(np.ceil(min(1.0, y + h) * height))
return x0, y0, max(x1, x0), max(y1, y0)
def blur_effect(roi: np.ndarray, size: int) -> np.ndarray:
"""Strong, size-scaled blur: downsample, upsample, Gaussian."""
h, w = roi.shape[:2]
factor = max(4, size // 6)
small = cv2.resize(roi, (max(1, w // factor), max(1, h // factor)), interpolation=cv2.INTER_AREA)
up = cv2.resize(small, (w, h), interpolation=cv2.INTER_LINEAR)
k = max(3, (size // 3) | 1)
return cv2.GaussianBlur(up, (k, k), 0)
def pixelate_effect(roi: np.ndarray, size: int) -> np.ndarray:
h, w = roi.shape[:2]
block = max(4, size // 10)
small = cv2.resize(roi, (max(1, -(-w // block)), max(1, -(-h // block))), interpolation=cv2.INTER_AREA)
return cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
def _ring_color(frame: np.ndarray, region: np.ndarray, margin: int) -> tuple[int, int, int]:
"""Median colour just outside the region: the 'skin tone patch' cover fill."""
kernel = np.ones((2 * margin + 1, 2 * margin + 1), np.uint8)
ring = (cv2.dilate(region, kernel) > 0) & (region == 0)
if not ring.any():
return DEFAULT_FILL
b, g, r = np.median(frame[ring], axis=0)
return (int(b), int(g), int(r))
def _region_for(
plan: Plan, t_ms: int, frame_ms: float, width: int, height: int
) -> tuple[np.ndarray, int] | None:
"""Binary region mask (uint8 0/255) and its characteristic size in px."""
region = np.zeros((height, width), np.uint8)
size = 0
hold = plan.masks is None
box = interpolate(plan.times, plan.boxes, t_ms, frame_ms, hold_single=hold)
if box is not None:
x0, y0, x1, y1 = _pixel_box(box, plan.padding, width, height)
raw_w, raw_h = box[2] * width, box[3] * height
if plan.mode in ("partial_eyes", "partial_upper", "partial_lower"):
_partial_region(plan, region, box, (x0, y0, x1, y1), t_ms, frame_ms, width, height)
else:
region[y0:y1, x0:x1] = 255
size = max(size, int(min(raw_w, raw_h)))
if plan.masks is not None:
mask = plan.masks.lookup(t_ms, width, height)
if mask is not None:
ys, xs = np.nonzero(mask)
if xs.size:
extent = int(min(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1))
grow = int(round(extent * plan.padding / 2))
if grow > 0:
mask = cv2.dilate(mask, np.ones((2 * grow + 1, 2 * grow + 1), np.uint8))
region |= mask
size = max(size, extent)
if not region.any():
return None
return region, max(size, 1)
def _partial_region(
plan: Plan,
region: np.ndarray,
box: tuple[float, float, float, float],
padded: tuple[int, int, int, int],
t_ms: int,
frame_ms: float,
width: int,
height: int,
) -> None:
x0, y0, x1, y1 = padded
bx, by, bw, bh = box
points = interpolate(plan.landmark_times, plan.landmarks, t_ms, frame_ms, hold_single=False)
# Five-point landmarks: right eye, left eye, nose, mouth corners.
eyes = points[:2] if points and len(points) >= 2 else None
nose_y = points[2][1] if points and len(points) >= 3 else by + bh * 0.55
if plan.mode == "partial_eyes":
if eyes:
(ex1, ey1), (ex2, ey2) = eyes
else:
ex1, ey1, ex2, ey2 = bx + bw * 0.3, by + bh * 0.4, bx + bw * 0.7, by + bh * 0.4
cx, cy = (ex1 + ex2) / 2 * width, (ey1 + ey2) / 2 * height
angle = float(np.degrees(np.arctan2((ey2 - ey1) * height, (ex2 - ex1) * width)))
band_w = (x1 - x0) * 1.0
band_h = max(bh * height * 0.32 * (1 + plan.padding), 2.0)
rect = cv2.boxPoints(((cx, cy), (band_w, band_h), angle)).astype(np.int32)
cv2.fillConvexPoly(region, rect, 255)
elif plan.mode == "partial_upper":
split = int(round(min(max(nose_y, by), by + bh) * height))
region[y0 : max(split, y0), x0:x1] = 255
else:
split = int(round(min(max(nose_y, by), by + bh) * height))
region[min(split, y1) : y1, x0:x1] = 255
def apply_plan(frame: np.ndarray, plan: Plan, t_ms: int, frame_ms: float) -> None:
height, width = frame.shape[:2]
found = _region_for(plan, t_ms, frame_ms, width, height)
if found is None:
return
region, size = found
radius = int(round(plan.feather * size))
ys, xs = np.nonzero(region)
margin = radius + 2
x0, x1 = max(0, int(xs.min()) - margin), min(width, int(xs.max()) + margin + 1)
y0, y1 = max(0, int(ys.min()) - margin), min(height, int(ys.max()) + margin + 1)
roi = frame[y0:y1, x0:x1]
roi_region = region[y0:y1, x0:x1]
mode = plan.mode
if mode in ("blur", "partial_upper", "partial_lower"):
effected = blur_effect(roi, size)
elif mode == "pixelate":
effected = pixelate_effect(roi, size)
else:
if mode == "cover" and plan.color is None:
fill = _ring_color(frame, region, max(4, size // 20))
else:
fill = plan.color or DEFAULT_FILL
effected = np.empty_like(roi)
effected[:] = fill
alpha = roi_region.astype(np.float32) / 255.0
if radius > 0:
kernel = np.ones((2 * radius + 1, 2 * radius + 1), np.uint8)
grown = cv2.dilate(roi_region, kernel).astype(np.float32) / 255.0
k = 2 * radius + 1
soft = cv2.GaussianBlur(grown, (k, k), 0)
alpha = np.maximum(alpha, soft)
alpha = alpha[..., None]
blended = roi.astype(np.float32) * (1.0 - alpha) + effected.astype(np.float32) * alpha
roi[:] = np.clip(np.rint(blended), 0, 255).astype(np.uint8)
def apply_redactions(frame: np.ndarray, t_ms: int, plans: list[Plan], frame_ms: float) -> np.ndarray:
for plan in plans:
apply_plan(frame, plan, t_ms, frame_ms)
return frame
def run(ctx: Ctx, body: RedactRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
ctx.registry.runtime("redact")
out_real = paths.output_file(ctx.settings.media_root, body.outPath)
if os.path.splitext(out_real)[1].lower() not in OUTPUT_EXTENSIONS:
raise BadRequest("outPath must end in .mkv, .mp4 or .mov")
if out_real == real:
raise BadRequest("outPath must differ from path")
frame_ms = info.frame_ms()
plans = [build_plan(ctx, r, frame_ms) for r in body.redactions]
def per_frame(_index: int, t_ms: int, frame: np.ndarray) -> np.ndarray:
return apply_redactions(frame, t_ms, plans, frame_ms)
count = media_io.transform_video(real, out_real, info, cancel, per_frame)
return {"outPath": body.outPath, "framesProcessed": count}

View File

@ -0,0 +1,58 @@
"""saliency: spectral residual (Hou & Zhang, 2007), implemented with numpy/OpenCV.
The point is the saliency-weighted centroid of the top 5 % most salient
pixels; strength is how much those pixels stand out against the frame mean.
"""
from __future__ import annotations
import threading
from typing import Any
import cv2
import numpy as np
from .. import media_io
from ..schemas import TimesRequest
from .common import Ctx, clamp01, open_input, unique_sorted
WORK_SIZE = 64
def saliency_map(frame: np.ndarray) -> np.ndarray:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).astype(np.float32)
small = cv2.resize(gray, (WORK_SIZE, WORK_SIZE), interpolation=cv2.INTER_AREA)
spectrum = np.fft.fft2(small)
log_amp = np.log(np.abs(spectrum) + 1e-8)
phase = np.angle(spectrum)
residual = log_amp - cv2.blur(log_amp, (3, 3))
sal = np.abs(np.fft.ifft2(np.exp(residual + 1j * phase))) ** 2
sal = cv2.GaussianBlur(sal.astype(np.float32), (0, 0), 2.5)
peak = float(sal.max())
return sal / peak if peak > 0 else sal
def salient_point(frame: np.ndarray) -> tuple[float, float, float]:
sal = saliency_map(frame)
threshold = float(np.quantile(sal, 0.95))
top = np.where(sal >= threshold, sal, 0.0)
total = float(top.sum())
if total <= 0:
return 0.5, 0.5, 0.0
ys, xs = np.mgrid[0 : sal.shape[0], 0 : sal.shape[1]]
cx = float((xs * top).sum() / total + 0.5) / sal.shape[1]
cy = float((ys * top).sum() / total + 0.5) / sal.shape[0]
mean = float(sal.mean())
top_mean = float(sal[sal >= threshold].mean())
strength = (top_mean - mean) / top_mean if top_mean > 0 else 0.0
return clamp01(cx), clamp01(cy), clamp01(strength)
def run(ctx: Ctx, body: TimesRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
ctx.registry.runtime("saliency")
points = []
for t_ms, frame in media_io.frames_at(real, info, unique_sorted(body.timesMs), cancel, 512):
cx, cy, strength = salient_point(frame)
points.append({"tMs": t_ms, "cx": cx, "cy": cy, "strength": strength})
return {"points": points}

View File

@ -0,0 +1,103 @@
"""detectScenes: PySceneDetect (adaptive) for shots, histogram grouping for scenes."""
from __future__ import annotations
import threading
from typing import Any
import cv2
import numpy as np
from scenedetect import AdaptiveDetector, SceneManager, StatsManager, open_video
from .. import media_io
from ..errors import Cancelled
from ..schemas import PathRequest
from .common import Ctx, clamp01, open_input
# Neighbouring shots whose middle frames correlate at least this much share a scene.
SCENE_HIST_CORRELATION = 0.6
ADAPTIVE_THRESHOLD = 3.0
class SceneDetector:
def detect(self, path: str, cancel: threading.Event) -> list[tuple[float, float, float]]:
"""Return shots as (start_s, end_s, score in [0,1])."""
video = open_video(path, backend="opencv")
stats = StatsManager()
manager = SceneManager(stats_manager=stats)
manager.add_detector(AdaptiveDetector(adaptive_threshold=ADAPTIVE_THRESHOLD))
stop = threading.Event()
def watch() -> None:
while not stop.wait(0.2):
if cancel.is_set():
manager.stop()
return
watcher = threading.Thread(target=watch, daemon=True)
watcher.start()
try:
manager.detect_scenes(video=video)
finally:
stop.set()
if cancel.is_set():
raise Cancelled()
scenes = manager.get_scene_list(start_in_scene=True)
ratio_key = next(
(k for k in AdaptiveDetector().get_metrics() if k.startswith("adaptive_ratio")), None
)
shots: list[tuple[float, float, float]] = []
for index, (start, end) in enumerate(scenes):
score = 1.0
if index > 0 and ratio_key:
value = (
stats.get_metrics(start.frame_num, [ratio_key])[0]
if stats.metrics_exist(start.frame_num, [ratio_key])
else None
)
score = clamp01((value or ADAPTIVE_THRESHOLD) / (2 * ADAPTIVE_THRESHOLD))
shots.append((start.seconds, end.seconds, score))
return shots
def load_scene_detector(_files: dict[str, str], _settings: Any) -> SceneDetector:
return SceneDetector()
def _histogram(frame: np.ndarray) -> np.ndarray:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
hist = cv2.calcHist([hsv], [0, 1], None, [32, 16], [0, 180, 0, 256])
cv2.normalize(hist, hist)
return hist
def run(ctx: Ctx, body: PathRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
detector = ctx.registry.runtime("detectScenes")
raw = detector.detect(real, cancel)
duration_ms = int(round(info.duration_s * 1000))
if not raw:
raw = [(0.0, info.duration_s, 1.0)]
shots: list[dict[str, Any]] = []
for start_s, end_s, score in raw:
start_ms = int(round(start_s * 1000))
end_ms = max(int(round(end_s * 1000)), start_ms)
if duration_ms:
end_ms = min(end_ms, duration_ms)
shots.append({"startMs": start_ms, "endMs": end_ms, "score": float(score)})
scenes: list[dict[str, int]] = []
previous: np.ndarray | None = None
for shot in shots:
middle = (shot["startMs"] + shot["endMs"]) // 2
hist = _histogram(media_io.frame_at(real, info, middle, cancel, max_side=320))
same = (
previous is not None
and cv2.compareHist(previous, hist, cv2.HISTCMP_CORREL) >= SCENE_HIST_CORRELATION
)
if same and scenes:
scenes[-1]["endMs"] = shot["endMs"]
else:
scenes.append({"startMs": shot["startMs"], "endMs": shot["endMs"]})
previous = hist
return {"shots": shots, "scenes": scenes}

View File

@ -0,0 +1,244 @@
"""segmentPrompted: SAM2 (Apache-2.0) from a click or box, propagated over the video.
Frames are sampled at up to 15 fps and fed to the SAM2 video predictor in
chunks so memory stays bounded; each chunk is re-prompted with the bounding
box of the previous chunk's last mask. Masks are written as `<tMs>.png`
(0 or 255) at the source resolution.
"""
from __future__ import annotations
import os
import tempfile
import threading
from collections.abc import Iterator
from typing import Any
import cv2
import numpy as np
from .. import paths
from ..errors import BadRequest, Cancelled, MediaError
from ..media_io import FFMPEG, run_cancellable, scaled_size
from ..schemas import SegmentPromptedRequest
from .common import Ctx, clamp01, norm_box, open_input
from .devices import torch_device
from .masks import MASK_NAME
SEGMENT_MAX_FPS = 15.0
SEGMENT_MAX_SIDE = 1024
CHUNK_FRAMES = 64
MaskStream = Iterator[tuple[int, np.ndarray, float]]
def _mask_box(mask: np.ndarray) -> tuple[int, int, int, int] | None:
ys, xs = np.nonzero(mask)
if xs.size == 0:
return None
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
class Sam2Segmenter:
def __init__(self, checkpoint: str, settings: Any) -> None:
import torch
from sam2.build_sam import build_sam2_video_predictor
self.torch = torch
self.device = torch_device(settings)
self.predictor = build_sam2_video_predictor(
"configs/sam2.1/sam2.1_hiera_t.yaml", checkpoint, device=self.device
)
self._lock = threading.Lock()
def _chunk(
self,
frames: list[str],
prompt_at: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
reverse: bool,
cancel: threading.Event,
) -> MaskStream:
with tempfile.TemporaryDirectory(prefix="sam2-") as chunk_dir:
for i, source in enumerate(frames):
os.symlink(source, os.path.join(chunk_dir, f"{i}.jpg"))
torch = self.torch
with torch.inference_mode():
state = self.predictor.init_state(video_path=chunk_dir, offload_video_to_cpu=True)
kwargs: dict[str, Any] = {}
if box is not None:
kwargs["box"] = np.array(box, dtype=np.float32)
if point is not None:
kwargs["points"] = np.array([point], dtype=np.float32)
kwargs["labels"] = np.array([1], dtype=np.int32)
self.predictor.add_new_points_or_box(state, frame_idx=prompt_at, obj_id=1, **kwargs)
for index, _, logits in self.predictor.propagate_in_video(
state, start_frame_idx=prompt_at, reverse=reverse
):
if cancel.is_set():
raise Cancelled()
values = logits[0, 0].float().cpu().numpy()
mask = values > 0
confidence = float(1.0 / (1.0 + np.exp(-values[mask])).mean()) if mask.any() else 0.0
yield index, mask, confidence
self.predictor.reset_state(state)
def segment(
self,
frames: list[str],
prompt_index: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
propagate: bool,
cancel: threading.Event,
) -> MaskStream:
with self._lock:
if not propagate:
for _, mask, confidence in self._chunk([frames[prompt_index]], 0, box, point, False, cancel):
yield prompt_index, mask, confidence
return
yield from self._forward(frames, prompt_index, box, point, cancel)
yield from self._backward(frames, prompt_index, box, point, cancel)
def _forward(
self,
frames: list[str],
anchor: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
cancel: threading.Event,
) -> MaskStream:
emit_anchor = True
while True:
chunk = frames[anchor : anchor + CHUNK_FRAMES]
last: np.ndarray | None = None
for index, mask, confidence in self._chunk(chunk, 0, box, point, False, cancel):
if index == 0 and not emit_anchor:
continue
last = mask
yield anchor + index, mask, confidence
end = anchor + len(chunk) - 1
box = _mask_box(last) if last is not None else None
if end >= len(frames) - 1 or box is None:
return
anchor, point, emit_anchor = end, None, False
def _backward(
self,
frames: list[str],
anchor: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
cancel: threading.Event,
) -> MaskStream:
while anchor > 0:
start = max(0, anchor - CHUNK_FRAMES + 1)
last: np.ndarray | None = None
for index, mask, confidence in self._chunk(
frames[start : anchor + 1], anchor - start, box, point, True, cancel
):
if start + index == anchor:
continue # emitted by the forward pass
last = mask
yield start + index, mask, confidence
box = _mask_box(last) if last is not None else None
if start == 0 or box is None:
return
anchor, point = start, None
def load_segmenter(files: dict[str, str], settings: Any) -> Sam2Segmenter:
return Sam2Segmenter(files["sam2.1_hiera_tiny.pt"], settings)
def _extract_frames(
path: str,
fps: float,
size: tuple[int, int],
directory: str,
cancel: threading.Event,
at_ms: int | None = None,
) -> list[str]:
argv = [FFMPEG, "-v", "error", "-nostdin"]
if at_ms is not None:
argv += ["-ss", f"{at_ms / 1000:.3f}"]
argv += ["-i", path, "-map", "0:v:0", "-an", "-sn"]
filters = [] if at_ms is not None else [f"fps={fps:.6f}"]
filters.append(f"scale={size[0]}:{size[1]}:flags=area")
argv += ["-vf", ",".join(filters)]
if at_ms is not None:
argv += ["-frames:v", "1"]
argv += ["-q:v", "2", "-start_number", "0", os.path.join(directory, "%06d.jpg")]
result = run_cancellable(argv, cancel)
if result.returncode != 0:
raise MediaError("internal", "frame extraction failed")
names = sorted(n for n in os.listdir(directory) if n.endswith(".jpg"))
if not names:
raise MediaError("internal", "no frames extracted")
return [os.path.join(directory, n) for n in names]
def run(ctx: Ctx, body: SegmentPromptedRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
out_dir = paths.output_dir(ctx.settings.media_root, body.outDir)
segmenter = ctx.registry.runtime("segmentPrompted")
fps = min(info.fps, SEGMENT_MAX_FPS)
width, height = scaled_size(info.width, info.height, SEGMENT_MAX_SIDE)
prompt = body.prompt
if prompt.tMs > int(info.duration_s * 1000) + 1:
raise BadRequest("prompt.tMs is after the end of the video")
with tempfile.TemporaryDirectory(prefix="segment-") as work:
if body.propagate:
frames = _extract_frames(real, fps, (width, height), work, cancel)
times = [int(round(i * 1000.0 / fps)) for i in range(len(frames))]
prompt_index = int(np.argmin([abs(t - prompt.tMs) for t in times]))
else:
frames = _extract_frames(real, fps, (width, height), work, cancel, at_ms=prompt.tMs)
times = [prompt.tMs]
prompt_index = 0
box = None
if prompt.box is not None:
b = prompt.box
box = (
int(round(b.x * width)),
int(round(b.y * height)),
int(round(clamp01(b.x + b.w) * width)),
int(round(clamp01(b.y + b.h) * height)),
)
point = (
(int(round(prompt.point.x * width)), int(round(prompt.point.y * height)))
if prompt.point
else None
)
# Replace masks from an earlier run in the same directory.
for name in os.listdir(out_dir):
if MASK_NAME.match(name):
os.unlink(os.path.join(out_dir, name))
keyframes = []
for index, mask, confidence in segmenter.segment(
frames, prompt_index, box, point, body.propagate, cancel
):
if cancel.is_set():
raise Cancelled()
t_ms = times[index]
full = cv2.resize(
mask.astype(np.uint8) * 255, (info.width, info.height), interpolation=cv2.INTER_NEAREST
)
found = _mask_box(full > 0)
if found is None:
continue
if not cv2.imwrite(os.path.join(out_dir, f"{t_ms}.png"), full):
raise MediaError("internal", "could not write mask")
x0, y0, x1, y1 = found
keyframes.append(
{
"tMs": t_ms,
"box": norm_box(x0, y0, x1 - x0, y1 - y0, info.width, info.height),
"confidence": clamp01(confidence),
}
)
keyframes.sort(key=lambda k: k["tMs"])
score = float(np.mean([k["confidence"] for k in keyframes])) if keyframes else 0.0
return {"maskDir": body.outDir, "keyframes": keyframes, "score": score}

View File

@ -0,0 +1,20 @@
"""synthesizeIdentity: deliberately unavailable.
No generator + face-swap pipeline with a commercial licence exists that this
sidecar can ship, so the operation always answers 501. The request schema
forbids unknown fields, so a target-face image is rejected before this runs.
"""
from __future__ import annotations
import threading
from typing import Any
from ..errors import Unsupported
from ..schemas import SynthesizeIdentityRequest
from .common import Ctx
def run(ctx: Ctx, _body: SynthesizeIdentityRequest, _cancel: threading.Event) -> dict[str, Any]:
spec = ctx.registry.backend("synthesizeIdentity").spec
raise Unsupported(spec.unavailable_reason or "no commercially licensed model")

View File

@ -0,0 +1,57 @@
"""transcribe: faster-whisper with word timestamps."""
from __future__ import annotations
import os
import threading
from typing import Any
from ..errors import BadRequest, Cancelled
from ..schemas import TranscribeRequest
from .common import Ctx, clamp01, open_input
from .devices import ctranslate2_device
class WhisperTranscriber:
def __init__(self, model_dir: str, settings: Any) -> None:
from faster_whisper import WhisperModel
device = ctranslate2_device(settings)
self.model = WhisperModel(
model_dir,
device=device,
compute_type="float16" if device == "cuda" else "int8",
)
def transcribe(
self, path: str, language: str | None, cancel: threading.Event
) -> tuple[str | None, list[dict[str, Any]]]:
segments, info = self.model.transcribe(path, language=language, word_timestamps=True, vad_filter=True)
words: list[dict[str, Any]] = []
for segment in segments:
if cancel.is_set():
raise Cancelled()
for word in segment.words or []:
start = int(round(word.start * 1000))
words.append(
{
"startMs": start,
"endMs": max(int(round(word.end * 1000)), start),
"text": word.word.strip(),
"confidence": clamp01(word.probability),
}
)
return info.language, words
def load_transcriber(files: dict[str, str], settings: Any) -> WhisperTranscriber:
return WhisperTranscriber(os.path.dirname(files["model.bin"]), settings)
def run(ctx: Ctx, body: TranscribeRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel, need_video=False)
if not info.has_audio:
raise BadRequest("input has no audio stream")
transcriber = ctx.registry.runtime("transcribe")
language, words = transcriber.transcribe(real, body.language, cancel)
return {"language": language, "words": [w for w in words if w["text"]]}

Some files were not shown because too many files have changed in this diff Show More