Compare commits
57 Commits
main
...
media-w3-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bab398cb52 | ||
|
|
844f7fe7c1 | ||
|
|
884d8903ca | ||
|
|
b4083844ce | ||
|
|
90f18cea52 | ||
|
|
d88e0de1bc | ||
|
|
8d07ab17d4 | ||
|
|
2973f5074b | ||
|
|
21edc4add7 | ||
|
|
b416ff0fdf | ||
|
|
f29e6379cb | ||
|
|
0e82982452 | ||
|
|
227703a78a | ||
|
|
4876c58fca | ||
|
|
7c744b98a0 | ||
|
|
3ebe0fe47b | ||
|
|
03e4961c9f | ||
|
|
ffcc47710d | ||
|
|
e601b26292 | ||
|
|
872fdd2194 | ||
|
|
87d5be4daf | ||
|
|
44396969fc | ||
|
|
393137be9a | ||
|
|
4721f00546 | ||
|
|
d6957c4670 | ||
|
|
439de0d936 | ||
|
|
3c13961948 | ||
|
|
f4bf545c45 | ||
|
|
7f6fc678c3 | ||
|
|
3f9a601dba | ||
|
|
8f38c22119 | ||
|
|
239a1eceb0 | ||
|
|
55e129cb7e | ||
|
|
b4222ac5ec | ||
|
|
bd5f637501 | ||
|
|
5e2094f2ad | ||
|
|
a40582165a | ||
|
|
2556ed197e | ||
|
|
475710e27e | ||
|
|
d43e374534 | ||
|
|
a6598f09d9 | ||
|
|
26d95a374e | ||
|
|
1aab5ab3a8 | ||
|
|
51445e9442 | ||
|
|
0ee6c664d2 | ||
|
|
c877343218 | ||
|
|
d3c6f262ad | ||
|
|
5e155a0850 | ||
|
|
c1c39b6e46 | ||
|
|
c3f06e4a12 | ||
|
|
0c4e0202f3 | ||
|
|
270ea8068a | ||
|
|
f98fd9703c | ||
|
|
759862fef4 | ||
|
|
b5cef21f44 | ||
|
|
7ccc96ac11 | ||
|
|
a663764ee8 |
12
.env.example
12
.env.example
@ -236,3 +236,15 @@ EVOO_COCKPIT_CORS_ORIGIN=
|
|||||||
# Expo: English or Simplified Chinese (en | zh-CN). Overridden by SecureStore key
|
# Expo: English or Simplified Chinese (en | zh-CN). Overridden by SecureStore key
|
||||||
# rakazo.uiLocale from Account → Language.
|
# rakazo.uiLocale from Account → Language.
|
||||||
# EXPO_PUBLIC_DEFAULT_UI_LOCALE=en
|
# 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
|
||||||
|
|||||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@ -170,6 +170,15 @@ jobs:
|
|||||||
- run: pnpm db:generate
|
- run: pnpm db:generate
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
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
|
- run: pnpm test
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
|
|||||||
32
.github/workflows/desktop-photos-helper.yml
vendored
Normal file
32
.github/workflows/desktop-photos-helper.yml
vendored
Normal 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
32
.github/workflows/media-worker.yml
vendored
Normal 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
|
||||||
12
.github/workflows/release-desktop.yml
vendored
12
.github/workflows/release-desktop.yml
vendored
@ -145,6 +145,14 @@ jobs:
|
|||||||
umask 077
|
umask 077
|
||||||
printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY"
|
printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY"
|
||||||
chmod 600 "$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
|
- name: Package signed and notarized universal macOS app
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
env:
|
env:
|
||||||
@ -174,6 +182,10 @@ jobs:
|
|||||||
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
|
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
|
||||||
grep -Fqx "owner: elie222" "$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"
|
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
|
- name: Require Windows signing credentials
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"@rakazo/core": "workspace:*",
|
"@rakazo/core": "workspace:*",
|
||||||
"@rakazo/db": "workspace:*",
|
"@rakazo/db": "workspace:*",
|
||||||
"@rakazo/logging": "workspace:*",
|
"@rakazo/logging": "workspace:*",
|
||||||
|
"@rakazo/media": "workspace:*",
|
||||||
"@rakazo/memory": "workspace:*",
|
"@rakazo/memory": "workspace:*",
|
||||||
"hono": "4.13.7"
|
"hono": "4.13.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import {
|
|||||||
createCloudAgentConnection,
|
createCloudAgentConnection,
|
||||||
createConnectorStack,
|
createConnectorStack,
|
||||||
createJobReconciler,
|
createJobReconciler,
|
||||||
|
createMediaJobHandlers,
|
||||||
|
createMediaServices,
|
||||||
createMessagingContextLoader,
|
createMessagingContextLoader,
|
||||||
createMessagingTeamChatSender,
|
createMessagingTeamChatSender,
|
||||||
createProfileConsolidationScheduler,
|
createProfileConsolidationScheduler,
|
||||||
@ -92,6 +94,7 @@ import { cors } from "hono/cors";
|
|||||||
import type { AppEnv } from "./env.js";
|
import type { AppEnv } from "./env.js";
|
||||||
import { loadEnv } from "./env.js";
|
import { loadEnv } from "./env.js";
|
||||||
import { mountLocalSettings } from "./local-settings.js";
|
import { mountLocalSettings } from "./local-settings.js";
|
||||||
|
import { mountMediaHttpRoutes } from "./media-http.js";
|
||||||
import {
|
import {
|
||||||
createMessagingInboundHandler,
|
createMessagingInboundHandler,
|
||||||
teamChatSenderCanWakeMessageRoutines,
|
teamChatSenderCanWakeMessageRoutines,
|
||||||
@ -414,6 +417,18 @@ export async function createApp(
|
|||||||
shutdownSignal: shutdown.signal,
|
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({
|
const jobHandlers = createBackgroundJobHandlers({
|
||||||
executor,
|
executor,
|
||||||
prisma,
|
prisma,
|
||||||
@ -421,6 +436,7 @@ export async function createApp(
|
|||||||
home,
|
home,
|
||||||
jobs,
|
jobs,
|
||||||
events,
|
events,
|
||||||
|
...(mediaJobs ? { media: mediaJobs.handlers } : {}),
|
||||||
workerId: "api",
|
workerId: "api",
|
||||||
runtime,
|
runtime,
|
||||||
secretStore: secrets,
|
secretStore: secrets,
|
||||||
@ -438,6 +454,11 @@ export async function createApp(
|
|||||||
});
|
});
|
||||||
await graphileWorkerHost.start(jobHandlers);
|
await graphileWorkerHost.start(jobHandlers);
|
||||||
}
|
}
|
||||||
|
if (mediaJobs) {
|
||||||
|
void mediaJobs
|
||||||
|
.resume()
|
||||||
|
.catch((error) => getLogger().error("media ingest resume failed", error));
|
||||||
|
}
|
||||||
const reconciler = inMemoryJobs
|
const reconciler = inMemoryJobs
|
||||||
? createJobReconciler({
|
? createJobReconciler({
|
||||||
prisma,
|
prisma,
|
||||||
@ -469,6 +490,7 @@ export async function createApp(
|
|||||||
remoteConnectors,
|
remoteConnectors,
|
||||||
artifacts,
|
artifacts,
|
||||||
dataDir: env.dataDir,
|
dataDir: env.dataDir,
|
||||||
|
mediaServices,
|
||||||
messaging: {
|
messaging: {
|
||||||
enabled: Boolean(messaging),
|
enabled: Boolean(messaging),
|
||||||
providers: messaging?.platforms().map((platform) => platform.provider) ?? [],
|
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 });
|
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
|
||||||
return actor;
|
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 });
|
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
|
||||||
// Shared with stop so a shutdown during retry delays does not restart polling.
|
// Shared with stop so a shutdown during retry delays does not restart polling.
|
||||||
let messagingStopped = false;
|
let messagingStopped = false;
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import type { MediaConfig } from "@rakazo/adapters";
|
||||||
import {
|
import {
|
||||||
|
mediaConfigFromEnv,
|
||||||
resolveCloudAgentProvider,
|
resolveCloudAgentProvider,
|
||||||
resolveDeploymentModel,
|
resolveDeploymentModel,
|
||||||
resolveSandboxProvider,
|
resolveSandboxProvider,
|
||||||
@ -15,6 +17,8 @@ export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapt
|
|||||||
|
|
||||||
export interface AppEnv {
|
export interface AppEnv {
|
||||||
nodeEnv: string;
|
nodeEnv: string;
|
||||||
|
/** Media Studio; disabled unless a sidecar (or the scripted emulator) is configured. */
|
||||||
|
media: MediaConfig;
|
||||||
desktopStackToken?: string;
|
desktopStackToken?: string;
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
realtimeDatabaseUrl: string;
|
realtimeDatabaseUrl: string;
|
||||||
@ -106,6 +110,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|||||||
const updaterToken = optional(source.RAKAZO_UPDATER_TOKEN);
|
const updaterToken = optional(source.RAKAZO_UPDATER_TOKEN);
|
||||||
return {
|
return {
|
||||||
nodeEnv: source.NODE_ENV ?? "",
|
nodeEnv: source.NODE_ENV ?? "",
|
||||||
|
media: mediaConfigFromEnv(source),
|
||||||
databaseUrl: required(source, "DATABASE_URL"),
|
databaseUrl: required(source, "DATABASE_URL"),
|
||||||
realtimeDatabaseUrl: source.REALTIME_DATABASE_URL ?? required(source, "DATABASE_URL"),
|
realtimeDatabaseUrl: source.REALTIME_DATABASE_URL ?? required(source, "DATABASE_URL"),
|
||||||
desktopStackToken: optional(source.RAKAZO_DESKTOP_STACK_TOKEN),
|
desktopStackToken: optional(source.RAKAZO_DESKTOP_STACK_TOKEN),
|
||||||
|
|||||||
265
apps/api/src/media-http.ts
Normal file
265
apps/api/src/media-http.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
389
apps/api/src/media-test-fakes.ts
Normal file
389
apps/api/src/media-test-fakes.ts
Normal 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" },
|
||||||
|
};
|
||||||
|
}
|
||||||
244
apps/api/src/media-uploads.ts
Normal file
244
apps/api/src/media-uploads.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
516
apps/api/src/media.postgres.test.ts
Normal file
516
apps/api/src/media.postgres.test.ts
Normal 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
333
apps/api/src/media.test.ts
Normal 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
552
apps/api/src/media.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -24,6 +24,7 @@ import type {
|
|||||||
ConnectorRegistry,
|
ConnectorRegistry,
|
||||||
EncryptedSecretStore,
|
EncryptedSecretStore,
|
||||||
IntegrationProviderSettings,
|
IntegrationProviderSettings,
|
||||||
|
MediaServices,
|
||||||
MemoryProviderResolver,
|
MemoryProviderResolver,
|
||||||
PiOAuthLogins,
|
PiOAuthLogins,
|
||||||
RemoteConnectorDependencies,
|
RemoteConnectorDependencies,
|
||||||
@ -166,6 +167,7 @@ import {
|
|||||||
renameMainConversation,
|
renameMainConversation,
|
||||||
} from "./main-conversation.js";
|
} from "./main-conversation.js";
|
||||||
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
||||||
|
import { createMediaRouter } from "./media.js";
|
||||||
import {
|
import {
|
||||||
disconnectMemoryProvider,
|
disconnectMemoryProvider,
|
||||||
getMemoryProviderConfig,
|
getMemoryProviderConfig,
|
||||||
@ -463,6 +465,8 @@ export interface RouterDeps {
|
|||||||
remoteConnectors?: RemoteConnectorDependencies;
|
remoteConnectors?: RemoteConnectorDependencies;
|
||||||
artifacts: ArtifactStore;
|
artifacts: ArtifactStore;
|
||||||
dataDir: string;
|
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. */
|
/** Present when the external messaging surface is enabled. */
|
||||||
messaging?: { enabled: boolean; providers: string[]; openSignup: boolean };
|
messaging?: { enabled: boolean; providers: string[]; openSignup: boolean };
|
||||||
env: {
|
env: {
|
||||||
@ -526,6 +530,11 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return os.router({
|
return os.router({
|
||||||
|
media: createMediaRouter({
|
||||||
|
prisma: deps.prisma,
|
||||||
|
services: deps.mediaServices ?? null,
|
||||||
|
dataDir: deps.dataDir,
|
||||||
|
}),
|
||||||
aiConsent: {
|
aiConsent: {
|
||||||
status: authed.aiConsent.status.handler(({ context, input }) =>
|
status: authed.aiConsent.status.handler(({ context, input }) =>
|
||||||
aiConsentStatus(deps, context.actor, input),
|
aiConsentStatus(deps, context.actor, input),
|
||||||
|
|||||||
2
apps/desktop/native/photos-helper/.gitignore
vendored
Normal file
2
apps/desktop/native/photos-helper/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
18
apps/desktop/native/photos-helper/Package.swift
Normal file
18
apps/desktop/native/photos-helper/Package.swift
Normal 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"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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.")
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/desktop/native/photos-helper/entitlements.mac.plist
Normal file
16
apps/desktop/native/photos-helper/entitlements.mac.plist
Normal 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>
|
||||||
@ -16,6 +16,8 @@
|
|||||||
"release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never"
|
"release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@orpc/client": "1.15.0",
|
||||||
|
"@orpc/contract": "1.15.0",
|
||||||
"@rakazo/contracts": "workspace:*",
|
"@rakazo/contracts": "workspace:*",
|
||||||
"electron-updater": "6.8.9"
|
"electron-updater": "6.8.9"
|
||||||
},
|
},
|
||||||
@ -61,7 +63,18 @@
|
|||||||
"dmg",
|
"dmg",
|
||||||
"zip"
|
"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": {
|
"win": {
|
||||||
"icon": "assets/icon.ico",
|
"icon": "assets/icon.ico",
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts/local-settings";
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
@ -32,6 +35,14 @@ import {
|
|||||||
stackDir,
|
stackDir,
|
||||||
stackResourceDir,
|
stackResourceDir,
|
||||||
} from "./local-stack.js";
|
} 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 { oauthCallbackFrom } from "./oauth-callback.js";
|
||||||
import {
|
import {
|
||||||
bundledRendererCandidates,
|
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);
|
ipcMain.handle("desktop.platform", () => process.platform);
|
||||||
const browserPreview = new BrowserPreviewController();
|
const browserPreview = new BrowserPreviewController();
|
||||||
// The in-app browser answers only the app window's own top frame, never a page inside it.
|
// The in-app browser answers only the app window's own top frame, never a page inside it.
|
||||||
|
|||||||
161
apps/desktop/src/media-ipc.test.ts
Normal file
161
apps/desktop/src/media-ipc.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
apps/desktop/src/media-ipc.ts
Normal file
126
apps/desktop/src/media-ipc.ts
Normal 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." };
|
||||||
|
}
|
||||||
437
apps/desktop/src/media-photos-helper.test.ts
Normal file
437
apps/desktop/src/media-photos-helper.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
512
apps/desktop/src/media-photos-helper.ts
Normal file
512
apps/desktop/src/media-photos-helper.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
655
apps/desktop/src/media-photos-sync.test.ts
Normal file
655
apps/desktop/src/media-photos-sync.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
581
apps/desktop/src/media-photos-sync.ts
Normal file
581
apps/desktop/src/media-photos-sync.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -9,6 +9,18 @@ contextBridge.exposeInMainWorld("rakazoDesktop", {
|
|||||||
request: (pathname, body) =>
|
request: (pathname, body) =>
|
||||||
ipcRenderer.invoke("desktop.localSettings.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: {
|
window: {
|
||||||
close: () => ipcRenderer.invoke("desktop.window.close"),
|
close: () => ipcRenderer.invoke("desktop.window.close"),
|
||||||
minimize: () => ipcRenderer.invoke("desktop.window.minimize"),
|
minimize: () => ipcRenderer.invoke("desktop.window.minimize"),
|
||||||
|
|||||||
@ -35,6 +35,7 @@ describe("desktop preload bridge", () => {
|
|||||||
"browserPreview",
|
"browserPreview",
|
||||||
"files",
|
"files",
|
||||||
"localSettings",
|
"localSettings",
|
||||||
|
"media",
|
||||||
"oauth",
|
"oauth",
|
||||||
"platform",
|
"platform",
|
||||||
"update",
|
"update",
|
||||||
@ -47,6 +48,12 @@ describe("desktop preload bridge", () => {
|
|||||||
"toggleMaximize",
|
"toggleMaximize",
|
||||||
]);
|
]);
|
||||||
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
|
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([
|
expect(Object.keys(bridge.browserPreview ?? {}).sort()).toEqual([
|
||||||
"back",
|
"back",
|
||||||
"forward",
|
"forward",
|
||||||
@ -97,6 +104,7 @@ describe("desktop preload bridge", () => {
|
|||||||
"browserPreview",
|
"browserPreview",
|
||||||
"files",
|
"files",
|
||||||
"localSettings",
|
"localSettings",
|
||||||
|
"media",
|
||||||
"oauth",
|
"oauth",
|
||||||
"platform",
|
"platform",
|
||||||
"update",
|
"update",
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
"expo-image-picker": "57.0.20",
|
"expo-image-picker": "57.0.20",
|
||||||
"expo-linking": "57.0.11",
|
"expo-linking": "57.0.11",
|
||||||
"expo-localization": "57.0.2",
|
"expo-localization": "57.0.2",
|
||||||
|
"expo-media-library": "57.0.5",
|
||||||
"expo-modules-core": "57.0.20",
|
"expo-modules-core": "57.0.20",
|
||||||
"expo-notifications": "57.0.21",
|
"expo-notifications": "57.0.21",
|
||||||
"expo-router": "57.0.24",
|
"expo-router": "57.0.24",
|
||||||
@ -44,6 +45,7 @@
|
|||||||
"expo-status-bar": "57.0.1",
|
"expo-status-bar": "57.0.1",
|
||||||
"expo-symbols": "57.0.3",
|
"expo-symbols": "57.0.3",
|
||||||
"expo-updates": "57.0.24",
|
"expo-updates": "57.0.24",
|
||||||
|
"expo-video": "57.0.5",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.3",
|
"react-native": "0.86.3",
|
||||||
|
|||||||
432
apps/web/e2e/fixtures/media-editor-data.ts
Normal file
432
apps/web/e2e/fixtures/media-editor-data.ts
Normal 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: "" }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
12
apps/web/e2e/fixtures/media-editor.html
Normal file
12
apps/web/e2e/fixtures/media-editor.html
Normal 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>
|
||||||
58
apps/web/e2e/fixtures/media-editor.tsx
Normal file
58
apps/web/e2e/fixtures/media-editor.tsx
Normal 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>,
|
||||||
|
);
|
||||||
60
apps/web/e2e/media-brand-kit.spec.ts
Normal file
60
apps/web/e2e/media-brand-kit.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
144
apps/web/e2e/media-project.spec.ts
Normal file
144
apps/web/e2e/media-project.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
117
apps/web/e2e/media-review.spec.ts
Normal file
117
apps/web/e2e/media-review.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
@ -24,6 +24,7 @@
|
|||||||
"@rakazo/core": "workspace:*",
|
"@rakazo/core": "workspace:*",
|
||||||
"@rakazo/ui-tokens": "workspace:*",
|
"@rakazo/ui-tokens": "workspace:*",
|
||||||
"@rakazo/ui-web": "workspace:*",
|
"@rakazo/ui-web": "workspace:*",
|
||||||
|
"@tanstack/react-virtual": "3.14.13",
|
||||||
"better-auth": "1.7.4",
|
"better-auth": "1.7.4",
|
||||||
"lucide-react": "1.44.0",
|
"lucide-react": "1.44.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
214
apps/web/src/components/media/editor/Player.tsx
Normal file
214
apps/web/src/components/media/editor/Player.tsx
Normal 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;
|
||||||
|
}
|
||||||
191
apps/web/src/components/media/editor/PublishPanel.tsx
Normal file
191
apps/web/src/components/media/editor/PublishPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
apps/web/src/components/media/editor/Timeline.tsx
Normal file
321
apps/web/src/components/media/editor/Timeline.tsx
Normal 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;
|
||||||
|
}
|
||||||
60
apps/web/src/components/media/editor/chrome.tsx
Normal file
60
apps/web/src/components/media/editor/chrome.tsx
Normal 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);
|
||||||
|
}
|
||||||
147
apps/web/src/components/media/editor/fields.tsx
Normal file
147
apps/web/src/components/media/editor/fields.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/src/components/media/editor/media-files.ts
Normal file
93
apps/web/src/components/media/editor/media-files.ts
Normal 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]);
|
||||||
|
}
|
||||||
135
apps/web/src/components/media/editor/renders.test.ts
Normal file
135
apps/web/src/components/media/editor/renders.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
108
apps/web/src/components/media/editor/renders.ts
Normal file
108
apps/web/src/components/media/editor/renders.ts
Normal 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, "");
|
||||||
|
}
|
||||||
162
apps/web/src/components/media/editor/review-parts.tsx
Normal file
162
apps/web/src/components/media/editor/review-parts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/web/src/components/media/editor/timeline.test.ts
Normal file
152
apps/web/src/components/media/editor/timeline.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
271
apps/web/src/components/media/editor/timeline.ts
Normal file
271
apps/web/src/components/media/editor/timeline.ts
Normal 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")}`;
|
||||||
|
}
|
||||||
587
apps/web/src/pages/media/BrandKitView.tsx
Normal file
587
apps/web/src/pages/media/BrandKitView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/src/pages/media/CollectionView.tsx
Normal file
6
apps/web/src/pages/media/CollectionView.tsx
Normal 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;
|
||||||
|
}
|
||||||
450
apps/web/src/pages/media/EditorView.tsx
Normal file
450
apps/web/src/pages/media/EditorView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/src/pages/media/LibraryView.tsx
Normal file
6
apps/web/src/pages/media/LibraryView.tsx
Normal 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;
|
||||||
|
}
|
||||||
34
apps/web/src/pages/media/MediaStudio.tsx
Normal file
34
apps/web/src/pages/media/MediaStudio.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/src/pages/media/PeopleView.tsx
Normal file
6
apps/web/src/pages/media/PeopleView.tsx
Normal 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;
|
||||||
|
}
|
||||||
292
apps/web/src/pages/media/ProjectView.tsx
Normal file
292
apps/web/src/pages/media/ProjectView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/src/pages/media/ResultsView.tsx
Normal file
6
apps/web/src/pages/media/ResultsView.tsx
Normal 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;
|
||||||
|
}
|
||||||
597
apps/web/src/pages/media/ReviewView.tsx
Normal file
597
apps/web/src/pages/media/ReviewView.tsx
Normal 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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
14
apps/web/src/pages/media/nav.ts
Normal file
14
apps/web/src/pages/media/nav.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ import {
|
|||||||
createCloudAgentConnection,
|
createCloudAgentConnection,
|
||||||
createConnectorStack,
|
createConnectorStack,
|
||||||
createJobReconciler,
|
createJobReconciler,
|
||||||
|
createMediaJobHandlers,
|
||||||
|
createMediaServices,
|
||||||
createMessagingContextLoader,
|
createMessagingContextLoader,
|
||||||
createPostgresReconciliationLeadership,
|
createPostgresReconciliationLeadership,
|
||||||
createProfileConsolidationScheduler,
|
createProfileConsolidationScheduler,
|
||||||
@ -31,6 +33,7 @@ import {
|
|||||||
LocalArtifactStore,
|
LocalArtifactStore,
|
||||||
McpConnector,
|
McpConnector,
|
||||||
McpOAuthBroker,
|
McpOAuthBroker,
|
||||||
|
mediaConfigFromEnv,
|
||||||
messagingEnvFromProcess,
|
messagingEnvFromProcess,
|
||||||
messagingPlatformsFromEnv,
|
messagingPlatformsFromEnv,
|
||||||
PiAgentRuntime,
|
PiAgentRuntime,
|
||||||
@ -201,6 +204,25 @@ async function main() {
|
|||||||
cloudAgent,
|
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({
|
const jobHandlers = createBackgroundJobHandlers({
|
||||||
executor,
|
executor,
|
||||||
prisma,
|
prisma,
|
||||||
@ -208,6 +230,7 @@ async function main() {
|
|||||||
home,
|
home,
|
||||||
jobs,
|
jobs,
|
||||||
events,
|
events,
|
||||||
|
...(mediaJobs ? { media: mediaJobs.handlers } : {}),
|
||||||
workerId: process.pid.toString(),
|
workerId: process.pid.toString(),
|
||||||
runtime,
|
runtime,
|
||||||
secretStore: secrets,
|
secretStore: secrets,
|
||||||
@ -235,6 +258,9 @@ async function main() {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, databaseCapacityBackoffMs(attempt)));
|
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({
|
const reconciler = createJobReconciler({
|
||||||
prisma,
|
prisma,
|
||||||
jobs,
|
jobs,
|
||||||
@ -253,6 +279,7 @@ async function main() {
|
|||||||
stopping = true;
|
stopping = true;
|
||||||
try {
|
try {
|
||||||
await reconciler.stop();
|
await reconciler.stop();
|
||||||
|
mediaAbort.abort();
|
||||||
await jobHost.stop();
|
await jobHost.stop();
|
||||||
await jobs.close();
|
await jobs.close();
|
||||||
await realtime.close();
|
await realtime.close();
|
||||||
|
|||||||
@ -129,6 +129,28 @@ services:
|
|||||||
data-init:
|
data-init:
|
||||||
condition: service_completed_successfully
|
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:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
@ -144,4 +166,5 @@ services:
|
|||||||
- api
|
- api
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
media-models:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
7
infra/media-worker/.dockerignore
Normal file
7
infra/media-worker/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
.venv
|
||||||
|
.venv-ml
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
5
infra/media-worker/.gitignore
vendored
Normal file
5
infra/media-worker/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
.venv
|
||||||
|
.venv-ml
|
||||||
|
__pycache__/
|
||||||
68
infra/media-worker/Dockerfile
Normal file
68
infra/media-worker/Dockerfile
Normal 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
|
||||||
53
infra/media-worker/LICENSES.md
Normal file
53
infra/media-worker/LICENSES.md
Normal 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 |
|
||||||
74
infra/media-worker/README.md
Normal file
74
infra/media-worker/README.md
Normal 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.
|
||||||
3
infra/media-worker/app/__init__.py
Normal file
3
infra/media-worker/app/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Local Media Studio inference sidecar."""
|
||||||
|
|
||||||
|
VERSION = "0.1.0"
|
||||||
35
infra/media-worker/app/__main__.py
Normal file
35
infra/media-worker/app/__main__.py
Normal 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))
|
||||||
22
infra/media-worker/app/auth.py
Normal file
22
infra/media-worker/app/auth.py
Normal 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")
|
||||||
80
infra/media-worker/app/config.py
Normal file
80
infra/media-worker/app/config.py
Normal 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,
|
||||||
|
)
|
||||||
41
infra/media-worker/app/encoders.py
Normal file
41
infra/media-worker/app/encoders.py
Normal 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))
|
||||||
62
infra/media-worker/app/errors.py
Normal file
62
infra/media-worker/app/errors.py
Normal 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}},
|
||||||
|
)
|
||||||
153
infra/media-worker/app/main.py
Normal file
153
infra/media-worker/app/main.py
Normal 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
|
||||||
435
infra/media-worker/app/media_io.py
Normal file
435
infra/media-worker/app/media_io.py
Normal 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)
|
||||||
506
infra/media-worker/app/models.py
Normal file
506
infra/media-worker/app/models.py
Normal 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)
|
||||||
1
infra/media-worker/app/ops/__init__.py
Normal file
1
infra/media-worker/app/ops/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""One module per operation family. Each exposes `run(ctx, body, cancel)`."""
|
||||||
163
infra/media-worker/app/ops/audio.py
Normal file
163
infra/media-worker/app/ops/audio.py
Normal 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,
|
||||||
|
}
|
||||||
62
infra/media-worker/app/ops/common.py
Normal file
62
infra/media-worker/app/ops/common.py
Normal 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
|
||||||
61
infra/media-worker/app/ops/devices.py
Normal file
61
infra/media-worker/app/ops/devices.py
Normal 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
|
||||||
147
infra/media-worker/app/ops/embeddings.py
Normal file
147
infra/media-worker/app/ops/embeddings.py
Normal 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)],
|
||||||
|
}
|
||||||
265
infra/media-worker/app/ops/faces.py
Normal file
265
infra/media-worker/app/ops/faces.py
Normal 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}
|
||||||
163
infra/media-worker/app/ops/inpaint.py
Normal file
163
infra/media-worker/app/ops/inpaint.py
Normal 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()}
|
||||||
54
infra/media-worker/app/ops/masks.py
Normal file
54
infra/media-worker/app/ops/masks.py
Normal 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
|
||||||
271
infra/media-worker/app/ops/redact.py
Normal file
271
infra/media-worker/app/ops/redact.py
Normal 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}
|
||||||
58
infra/media-worker/app/ops/saliency.py
Normal file
58
infra/media-worker/app/ops/saliency.py
Normal 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}
|
||||||
103
infra/media-worker/app/ops/scenes.py
Normal file
103
infra/media-worker/app/ops/scenes.py
Normal 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}
|
||||||
244
infra/media-worker/app/ops/segment.py
Normal file
244
infra/media-worker/app/ops/segment.py
Normal 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}
|
||||||
20
infra/media-worker/app/ops/synthesize.py
Normal file
20
infra/media-worker/app/ops/synthesize.py
Normal 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")
|
||||||
57
infra/media-worker/app/ops/transcribe.py
Normal file
57
infra/media-worker/app/ops/transcribe.py
Normal 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
Loading…
x
Reference in New Issue
Block a user