Repository Public
1import { randomUUID } from "node:crypto";2import { Notice, PageHead, Panel } from "@/components/studio/ui";3import { requireRole } from "@/lib/auth";4import { getPolicy, observedVideoRate } from "@/lib/budget";5import { env } from "@/lib/env";6import { SERIES } from "@/lib/taxonomy";7import { estimateImage, estimateVideo } from "@/providers/pricing";8import { usd } from "@/components/studio/ui";9import { createProject } from "../../../actions";1011export const metadata = { title: "Concept Lab" };12export default async function ConceptLab({ searchParams }: { searchParams: Promise<{ err?: string; series?: string }> }) {13 const sp = await searchParams;14 await requireRole("editor");15 const policy = await getPolicy();16 const res = env.videoResolution;17 const est = async (model: string) => { const v = estimateVideo(model, res, 6, await observedVideoRate(model, res)); const i = estimateImage(env.imageModel).usd ?? 0; return { perShot: v.usd === null ? null : v.usd + i, basis: v.basis }; };18 const [master, draft] = await Promise.all([est(env.videoModel), est(env.videoDraftModel)]);19 return (20 <>21 <PageHead title="Concept Lab" sub="Describe what the film should be about. The Creative Director returns four different pitches, scored for originality, feasibility, visual potential, clarity and artifact risk. Pitches cost a few cents; nothing expensive starts without the chosen autonomy mode allowing it." />22 <Notice err={sp.err} />23 <form action={createProject} className="grid gap-5 lg:grid-cols-[1.4fr_1fr]">24 <input type="hidden" name="nonce" value={randomUUID()} />25 <Panel title="Creative brief">26 <div className="space-y-4">27 <div><label htmlFor="theme" className="label">Theme or idea *</label><textarea id="theme" name="theme" rows={3} required minLength={3} maxLength={600} className="field mt-1" placeholder="e.g. Why a spinning coin seems to hover just before it falls" /></div>28 <div><label htmlFor="objective" className="label">Objective — what should the viewer leave with?</label><input id="objective" name="objective" maxLength={400} className="field mt-1" /></div>29 <div className="grid gap-4 sm:grid-cols-2">30 <div><label htmlFor="audience" className="label">Audience</label><input id="audience" name="audience" maxLength={200} className="field mt-1" placeholder="curious general audience" /></div>31 <div><label htmlFor="mood" className="label">Mood</label><input id="mood" name="mood" maxLength={120} className="field mt-1" placeholder="quiet, precise, a little funny" /></div>32 <div><label htmlFor="genre" className="label">Genre</label><input id="genre" name="genre" maxLength={120} className="field mt-1" /></div>33 <div><label htmlFor="seriesSlug" className="label">Series</label><select id="seriesSlug" name="seriesSlug" className="field mt-1" defaultValue={sp.series ?? ""}><option value="">Standalone film</option>{SERIES.map((s) => <option key={s.slug} value={s.slug}>{s.titleEn} ({s.kind})</option>)}</select></div>34 </div>35 <div><label htmlFor="constraints" className="label">Constraints</label><textarea id="constraints" name="constraints" rows={2} maxLength={600} className="field mt-1" placeholder="No people. One location. Must end on the object." /></div>36 </div>37 </Panel>38 <div className="space-y-5">39 <Panel title="Production">40 <div className="space-y-4">41 <div className="grid grid-cols-2 gap-4">42 <div><label htmlFor="language" className="label">Spoken language</label><select id="language" name="language" className="field mt-1"><option value="en">English</option><option value="fr">French</option><option value="bilingual">Bilingual (FR + EN)</option></select></div>43 <div><label htmlFor="shots" className="label">Shots</label><select id="shots" name="shots" className="field mt-1" defaultValue="5">{[3, 4, 5, 6].map((n) => <option key={n} value={n}>{n}</option>)}</select></div>44 </div>45 <fieldset><legend className="label">Video quality</legend>46 <label className="mt-2 flex items-start gap-3 text-sm"><input type="radio" name="quality" value="master" defaultChecked className="mt-1" /><span>Master — <span className="mono">{env.videoModel}</span><br /><span className="text-xs text-[var(--color-faint)]">≈ {usd(master.perShot)} per 6-second shot ({master.basis} price)</span></span></label>47 <label className="mt-2 flex items-start gap-3 text-sm"><input type="radio" name="quality" value="draft" className="mt-1" /><span>Draft — <span className="mono">{env.videoDraftModel}</span><br /><span className="text-xs text-[var(--color-faint)]">≈ {usd(draft.perShot)} per 6-second shot ({draft.basis} price)</span></span></label>48 </fieldset>49 <fieldset><legend className="label">Autonomy</legend>50 {([["manual", "Manual", "You start every stage yourself."], ["assisted", "Assisted", "Script, storyboard, keyframes and voice run on their own; the pipeline pauses before paid video generation."], ["autonomous", "Autonomous with budget", "Runs through to QA inside the ceilings. Release still needs your approval unless auto-publish is enabled in the policy."]] as const).map(([v, label, body]) => (51 <label key={v} className="mt-2 flex items-start gap-3 text-sm"><input type="radio" name="mode" value={v} defaultChecked={v === policy.default_mode} className="mt-1" /><span>{label}<br /><span className="text-xs text-[var(--color-faint)]">{body}</span></span></label>52 ))}53 </fieldset>54 </div>55 </Panel>56 <Panel title="Ceilings that apply">57 <p className="text-sm text-[var(--color-muted)]">Per shot {usd(policy.per_shot_usd)} · per film {usd(policy.per_film_usd)} · per day {usd(policy.per_day_usd)} · per month {usd(policy.per_month_usd)}.</p>58 <button className="btn btn-primary mt-4 w-full" disabled={policy.kill_switch}>{policy.kill_switch ? "Kill switch is on" : "Generate pitches"}</button>59 </Panel>60 </div>61 </form>62 </>63 );64}65