wip: media composition root

This commit is contained in:
Claude 2026-10-03 18:57:49 +00:00
parent 2973f5074b
commit d88e0de1bc
No known key found for this signature in database
8 changed files with 215 additions and 1 deletions

View File

@ -245,3 +245,6 @@ EVOO_COCKPIT_CORS_ORIGIN=
# MEDIA_WORKER_TOKEN=REPLACE_WITH_RANDOM_TOKEN # MEDIA_WORKER_TOKEN=REPLACE_WITH_RANDOM_TOKEN
# Private platform profiles that override the shipped ones. Keep outside the repo. # Private platform profiles that override the shipped ones. Keep outside the repo.
# MEDIA_PROFILES_DIR=~/.evoo/media-profiles # MEDIA_PROFILES_DIR=~/.evoo/media-profiles
# Media jobs per worker process: model inference (GPU) and ffmpeg work (CPU).
# MEDIA_GPU_CONCURRENCY=1
# MEDIA_CPU_CONCURRENCY=2

View File

@ -27,6 +27,8 @@ import {
createJobReconciler, createJobReconciler,
createMessagingContextLoader, createMessagingContextLoader,
createMessagingTeamChatSender, createMessagingTeamChatSender,
createMediaJobHandlers,
createMediaServices,
createProfileConsolidationScheduler, createProfileConsolidationScheduler,
createRunExecutor, createRunExecutor,
createRunSandbox, createRunSandbox,
@ -110,6 +112,7 @@ import {
TEAM_CHAT_STARTUP_SHUTDOWN_MS, TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js"; } from "./team-chat-startup.js";
import { mountTradingBotStatus } from "./trading-bot-proxy.js"; import { mountTradingBotStatus } from "./trading-bot-proxy.js";
import { mountMediaHttpRoutes } from "./media-http.js";
import { mountVoiceHttpRoutes } from "./voice.js"; import { mountVoiceHttpRoutes } from "./voice.js";
import { mountWebhookHttpRoutes } from "./webhook.js"; import { mountWebhookHttpRoutes } from "./webhook.js";
@ -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,9 @@ 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 +488,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 +603,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;

View File

@ -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),

View File

@ -24,6 +24,7 @@ import type {
ConnectorRegistry, ConnectorRegistry,
EncryptedSecretStore, EncryptedSecretStore,
IntegrationProviderSettings, IntegrationProviderSettings,
MediaServices,
MemoryProviderResolver, MemoryProviderResolver,
PiOAuthLogins, PiOAuthLogins,
RemoteConnectorDependencies, RemoteConnectorDependencies,
@ -464,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: {
@ -527,7 +530,11 @@ export function createRouter(deps: RouterDeps) {
}); });
return os.router({ return os.router({
media: createMediaRouter({ prisma: deps.prisma, dataDir: deps.dataDir }), 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),

View File

@ -12,6 +12,8 @@ import {
createJobReconciler, createJobReconciler,
createMessagingContextLoader, createMessagingContextLoader,
createPostgresReconciliationLeadership, createPostgresReconciliationLeadership,
createMediaJobHandlers,
createMediaServices,
createProfileConsolidationScheduler, createProfileConsolidationScheduler,
createRunExecutor, createRunExecutor,
createRunSandbox, createRunSandbox,
@ -29,6 +31,7 @@ import {
isPipedreamEnabled, isPipedreamEnabled,
LocalAgentHomeStore, LocalAgentHomeStore,
LocalArtifactStore, LocalArtifactStore,
mediaConfigFromEnv,
McpConnector, McpConnector,
McpOAuthBroker, McpOAuthBroker,
messagingEnvFromProcess, messagingEnvFromProcess,
@ -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();

View File

@ -85,6 +85,8 @@ export * from "./media-factory.js";
export * from "./media-ffmpeg-renderer.js"; export * from "./media-ffmpeg-renderer.js";
export * from "./media-folder-source.js"; export * from "./media-folder-source.js";
export * from "./media-folder-source-probe.js"; export * from "./media-folder-source-probe.js";
export * from "./media-ingest.js";
export { defaultMediaJobLimits, MediaJobLimiter, type MediaJobLimits } from "./media-jobs.js";
export * from "./media-marketing.js"; export * from "./media-marketing.js";
export * from "./media-marketing-copy.js"; export * from "./media-marketing-copy.js";
export * from "./media-marketing-files.js"; export * from "./media-marketing-files.js";
@ -103,6 +105,7 @@ export {
} from "./media-motion-renderer.js"; } from "./media-motion-renderer.js";
export * from "./media-publish.js"; export * from "./media-publish.js";
export * from "./media-publisher-bundle.js"; export * from "./media-publisher-bundle.js";
export * from "./media-services.js";
export { export {
createMediaRenderHandler, createMediaRenderHandler,
createMediaRenderService, createMediaRenderService,

View File

@ -0,0 +1,136 @@
import type {
AgentRunModel,
AgentRuntime,
ArtifactStore,
JobPublisher,
MediaJobHandlers,
RealtimeFanout,
} from "@rakazo/adapter-kit";
import type { ModelTier } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createMediaRepos } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { ProfileSet } from "@rakazo/media";
import { mergeProfiles } from "@rakazo/media";
import { BUILTIN_PROFILES_DIR, loadProfilesFromDir } from "@rakazo/media/node";
import type { MediaServiceDeps, MediaServices } from "./media-context.js";
import { createMediaModelClient, mediaTopic } from "./media-context.js";
import {
createMediaInferenceProvider,
createMediaSourceProvider,
type MediaConfig,
mediaEmulatorEnabled,
} from "./media-factory.js";
import { FfmpegMediaRenderer } from "./media-ffmpeg-renderer.js";
import {
createMediaIngestHandlers,
createMediaIngestService,
resumeMediaIngest,
} from "./media-ingest.js";
import type { MediaJobLimits } from "./media-jobs.js";
import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js";
import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js";
import { createMediaPrivacyService } from "./media-privacy.js";
import type { MediaUploadReminder } from "./media-publish.js";
import { BundleMediaPublisher } from "./media-publisher-bundle.js";
import { createMediaRenderHandler, createMediaRenderService } from "./media-render.js";
const PROFILE_CACHE_MS = 60_000;
/**
* Composition root for Media Studio, shared by the API and the worker. Returns
* null when no sidecar (or scripted emulator) is configured; media then stays
* hidden in every client.
*/
export function createMediaServices(input: {
config: MediaConfig;
prisma: PrismaClient;
jobs: JobPublisher;
artifacts: ArtifactStore;
runtime: AgentRuntime;
realtime: RealtimeFanout | null;
resolveModel: (scope: {
userId: string;
spaceId: string;
tier?: ModelTier;
}) => Promise<AgentRunModel>;
env?: NodeJS.ProcessEnv;
}): MediaServices | null {
const env = input.env ?? process.env;
if (!input.config.enabled) return null;
const inference = createMediaInferenceProvider(env);
if (!inference) return null;
const sources: MediaServiceDeps["sources"] = { folder: createMediaSourceProvider("folder", env) };
if (mediaEmulatorEnabled(env)) sources.emulator = createMediaSourceProvider("emulator", env);
let cached: { at: number; set: ProfileSet } | null = null;
const profiles = async (): Promise<ProfileSet> => {
if (cached && Date.now() - cached.at < PROFILE_CACHE_MS) return cached.set;
const [builtin, own] = await Promise.all([
loadProfilesFromDir(BUILTIN_PROFILES_DIR),
loadProfilesFromDir(input.config.profilesDir),
]);
const set = mergeProfiles(builtin.profiles, own.profiles);
set.errors = [...builtin.errors, ...own.errors];
for (const error of set.errors) getLogger().warn("media profile ignored", error);
cached = { at: Date.now(), set };
return set;
};
const deps: MediaServiceDeps = {
prisma: input.prisma,
repos: createMediaRepos(input.prisma),
jobs: input.jobs,
inference,
renderer: new FfmpegMediaRenderer({ root: input.config.mediaRoot }),
motion: new ResvgMotionGraphicsRenderer(),
publisher: new BundleMediaPublisher(),
shortener: null,
sources,
mediaRoot: input.config.mediaRoot,
profiles,
artifacts: input.artifacts,
model: createMediaModelClient({
prisma: input.prisma,
runtime: input.runtime,
resolveModel: input.resolveModel,
}),
publish: async (spaceId, event) => {
await input.realtime?.publish(mediaTopic(spaceId), JSON.stringify(event));
},
now: () => new Date(),
};
const privacy = createMediaPrivacyService(deps);
return {
deps,
ingest: createMediaIngestService(deps),
privacy,
render: createMediaRenderService(deps),
marketing: createMediaMarketingService(deps),
};
}
/**
* Every media job handler for one job host. Any process that runs a job host
* (worker, or the API in single-process mode) must register these, otherwise
* it would acknowledge media jobs without running them.
*/
export function createMediaJobHandlers(
services: MediaServices,
options: { signal?: AbortSignal; limits?: MediaJobLimits; remind?: MediaUploadReminder } = {},
): { handlers: MediaJobHandlers; resume: () => Promise<number> } {
const ingestOptions = {
...(options.signal ? { signal: options.signal } : {}),
...(options.limits ? { limits: options.limits } : {}),
};
return {
handlers: {
...createMediaIngestHandlers(services.deps, ingestOptions),
"media.render": createMediaRenderHandler(services.deps, services.privacy),
...createMediaMarketingJobHandlers(services.deps, services.marketing, {
...(options.remind ? { remind: options.remind } : {}),
}),
},
resume: () => resumeMediaIngest(services.deps, options.limits),
};
}

View File

@ -117,6 +117,12 @@ async function main() {
"packages/adapters/src/cloud-agent.postgres.test.ts", "packages/adapters/src/cloud-agent.postgres.test.ts",
"packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts",
"packages/adapters/src/profile-consolidation.postgres.test.ts", "packages/adapters/src/profile-consolidation.postgres.test.ts",
"packages/db/src/media.postgres.test.ts",
"packages/db/src/media-isolation.postgres.test.ts",
"packages/db/src/media-production.postgres.test.ts",
"packages/adapters/src/media-ingest.postgres.test.ts",
"packages/adapters/src/media-marketing.postgres.test.ts",
"apps/api/src/media.postgres.test.ts",
]; ];
// Each app reconciles all durable work in its database, including intentionally // Each app reconciles all durable work in its database, including intentionally
// unfinished fixture runs. Clone the pristine migrated schema so one suite // unfinished fixture runs. Clone the pristine migrated schema so one suite