103 lines
5.3 KiB
TypeScript
103 lines
5.3 KiB
TypeScript
import { MediaOperationSchema } from "@rakazo/contracts";
|
||
import type { PrismaClient } from "./client.js";
|
||
import { ensureMainBot } from "./main-agent.js";
|
||
import { createRepos } from "./repos.js";
|
||
import { requireMembership } from "./scope.js";
|
||
|
||
/**
|
||
* Idempotency key of the user's Studio specialist. Spawn keys are unique per space, so it
|
||
* carries the user id to stay unique in a space several users share.
|
||
*/
|
||
export function studioBotSpawnKey(userId: string): string {
|
||
return `media:studio:${userId}`;
|
||
}
|
||
|
||
/** Builtin tools Studio keeps: every media operation plus files, questions and memory. */
|
||
export const STUDIO_BOT_TOOLS = [
|
||
...MediaOperationSchema.options,
|
||
"list_files",
|
||
"read_file",
|
||
"attach_file",
|
||
"ask_user",
|
||
"message_user",
|
||
"show_card",
|
||
"remember",
|
||
"save_memory",
|
||
"recall_memory",
|
||
"web_search",
|
||
"web_fetch",
|
||
"skill_read",
|
||
"run_subagent",
|
||
] as const;
|
||
|
||
/** Product text: the main bot reads the description when routing, Studio follows the instructions. */
|
||
export const STUDIO_BOT_DEFAULTS = {
|
||
name: "Studio",
|
||
title: "Media producer",
|
||
description:
|
||
"Makes and art-directs images, brand visuals, video and audio: generates key visuals on ComfyUI, cuts, reframes and exports for every format, transcribes and subtitles.",
|
||
instructions: [
|
||
"You make, art-direct, and edit images, video and audio with the media tools, then hand the result back in a sentence or two.",
|
||
"",
|
||
"Work like a creative director on images and visual campaigns:",
|
||
"- Write richly art-directed generate_image prompts: name the subject, physical materials and textures, camera and focal length (e.g. 50mm f/1.4 editorial portrait, 90mm macro studio product shot, medium-format analog grain), lighting setup (diffused north window light, warm chiaroscuro, crisp rim light), color palette, and composition. Pair with a clean negative_prompt that excludes watermarks, gibberish text, plastic skin, and stock-photo clichés.",
|
||
"- Give every brand or campaign its own visual world: check memory first so you never repeat another project's palette, lighting mood, or aesthetic.",
|
||
"- Pick the aspect preset that fits the placement (landscape 16:9 for hero banners, portrait 4:5 or vertical 9:16 for editorial and social, square 1:1 for emblems and packshots) and use count (2–4) when exploring directions.",
|
||
"- Refine strong frames with edit_image or source_artifact_id and strength instead of starting over from scratch.",
|
||
"",
|
||
"Work like an editor on video and audio:",
|
||
"- Start from where the piece will run. Default to 9:16 for short-form social, 16:9 for YouTube and screens, 1:1 or 4:5 for feeds; export several formats in one export_video call.",
|
||
"- Hook in the first two seconds: open on the strongest moment, not the setup.",
|
||
"- Keep the pace tight: cut pauses, filler and repeats; short-form wants a cut every few seconds while the story stays easy to follow.",
|
||
"- Respect safe areas in 9:16: keep faces, text and subtitles out of the top 15% and bottom 20%, where the platform's buttons sit. When reframing, keep the subject centred.",
|
||
"- Most social video plays muted: transcribe speech, then burn in short subtitles.",
|
||
"- When the user has a logo or intro, brand the piece: the logo small in a top corner with overlay, the intro first with concat at start. Remember which files they are.",
|
||
"- Keep the source: every edit makes a new file, so try a cut and refine it.",
|
||
"",
|
||
"Media jobs run in the background and the user sees each one's card. Set continue_after when you need the generated files for your next step (they are materialized in attachments/<artifactId>.<ext>). Ask only when the purpose or format is unclear and a wrong guess would waste a long render.",
|
||
].join("\n"),
|
||
tags: ["media"],
|
||
} as const;
|
||
|
||
export interface StudioBotRef {
|
||
id: string;
|
||
spaceId: string;
|
||
}
|
||
|
||
/**
|
||
* Returns the user's Studio specialist, creating it next to the main bot (so it can join the
|
||
* main conversation and read its media) the first time media is needed. A Studio the user
|
||
* archived or deleted stays gone: this returns null instead of bringing it back.
|
||
*/
|
||
export async function ensureStudioBot(
|
||
prisma: PrismaClient,
|
||
userId: string,
|
||
): Promise<StudioBotRef | null> {
|
||
const main = await ensureMainBot(prisma, userId);
|
||
const spawnKey = studioBotSpawnKey(userId);
|
||
const existing = await prisma.bot.findUnique({
|
||
where: { spaceId_spawnKey: { spaceId: main.spaceId, spawnKey } },
|
||
select: { id: true, spaceId: true, archivedAt: true },
|
||
});
|
||
if (existing) return existing.archivedAt ? null : { id: existing.id, spaceId: existing.spaceId };
|
||
const deleted = await prisma.botDeletion.findFirst({
|
||
where: { spaceId: main.spaceId, spawnKey },
|
||
select: { id: true },
|
||
});
|
||
if (deleted) return null;
|
||
const actor = await requireMembership(prisma, userId, main.spaceId);
|
||
// createBot returns the bot a concurrent call created with the same spawn key.
|
||
const bot = await createRepos(prisma).createBot(actor, {
|
||
name: STUDIO_BOT_DEFAULTS.name,
|
||
title: STUDIO_BOT_DEFAULTS.title,
|
||
description: STUDIO_BOT_DEFAULTS.description,
|
||
instructions: STUDIO_BOT_DEFAULTS.instructions,
|
||
notifyOnFinish: true,
|
||
spawnKey,
|
||
tags: [...STUDIO_BOT_DEFAULTS.tags],
|
||
restrictBuiltinTools: true,
|
||
allowedBuiltinTools: [...STUDIO_BOT_TOOLS],
|
||
});
|
||
return { id: bot.id, spaceId: bot.spaceId };
|
||
}
|