diff --git a/.dockerignore b/.dockerignore index fc8ca90..c87c4e0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -43,6 +43,7 @@ LICENSE *.pem *.tsbuildinfo public/uploads +public/demo-assets *-before.png design-qa-*.png canvas-rollback-empty.png diff --git a/.gitignore b/.gitignore index a714c2a..8587721 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ package-lock.json # Runtime uploads and local QA artifacts /public/uploads/ +/public/demo-assets/ /*-before.png /design-qa-*.png /canvas-rollback-empty.png diff --git a/AGENTS.md b/AGENTS.md index 4e270db..33a11a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Do not add authentication, accounts, payments, subscriptions, credits, API-key i - Storage entitlement follows billing. BeatAPI managed R2/Files is allowed only with the official `https://api.beatapi.io` billing endpoint; any custom API host must use an operator-owned R2/S3-compatible bucket. File selection stays local and upload is allowed only after generation precheck. Never commit shared storage credentials. - The model catalog lives in `src/core/effects/effect-registry.ts`. - BeatAPI request mapping lives in `src/core/adapters/beatapi-adapter.ts`. -- Studio and Canvas share projects, tasks, and assets. +- Studio, Canvas, and Assets share projects, tasks, and assets. - Add message keys to both `messages/en.json` and `messages/zh.json`. - Do not edit `src/routeTree.gen.ts` manually. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a4a840..74abc1c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,7 +15,7 @@ Projects + Storage configuration ``` -Studio and Canvas are two views over the same project, generation, and asset services. Switching modes never creates a second project or backend. +Studio, Canvas, and Assets are three views over the same project, generation, and asset services. Switching modes never creates a second project or backend. ## Runtime flow diff --git a/DESIGN.md b/DESIGN.md index 81568de..b22a112 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,17 +1,17 @@ -# Design System — BeatAPI SaaS Template +# Design System — BeatDesign ## Product Context -- **What this is:** An open-source AI SaaS starter and a working demonstration of building on BeatAPI. +- **What this is:** An open-source, local-first AI creative workbench and a working demonstration of building on BeatAPI. - **Who it is for:** Independent developers and small product teams shipping AI media products for an international audience. - **Space:** Developer tools, AI creative software, and SaaS infrastructure. -- **Project type:** Marketing site, authenticated SaaS dashboard, guided studio, and node canvas. +- **Project type:** Marketing site, local project workspace, guided Studio, node Canvas, and shared Assets view. ## Aesthetic Direction - **Direction:** Warm technical editorial in marketing; cinematic black creative operating system across Studio, Canvas, and PromptWise-structured Pricing. - **Decoration:** Intentional. Typography, product media, fine grid lines, dark glass controls, and restrained highlights create character without ornamental UI noise. -- **Layout:** Hybrid. Marketing uses editorial scale and asymmetry; authenticated product surfaces use a strict, predictable grid. +- **Layout:** Hybrid. Marketing uses editorial scale and asymmetry; local project surfaces use a strict, predictable grid. - **Mood:** Capable, cinematic, and crafted. Creation surfaces should feel like focused production tools, while the marketing site stays warmer and more editorial. - **Reference language:** PromptWise is the visual reference, not a copied page or a separately selectable theme. @@ -30,7 +30,7 @@ - **Brand 600:** `#E9550B` — hover and pressed states. - **Brand soft:** `#FFF0E5` — selected surfaces and subtle callouts. - **Marketing canvas:** `#F6F0E7` — warm cream. -- **Studio / Canvas canvas:** `#08090A` — near-black production workspace. +- **Studio / Canvas / Assets canvas:** `#08090A` — near-black production workspace. - **Pricing canvas:** `#08090A` with graphite `#141517` plan cards, warm-white type, and PromptWise's original plan geometry. - **Dark surface:** `#111214`; raised surface `#17181B`; control surface `#202126`. - **Dark ink:** `#F6F6F4`; secondary `rgba(246,246,244,.58)`; faint `rgba(246,246,244,.34)`. @@ -39,7 +39,7 @@ - **Borders:** `#DDDED8`; strong `#C7C9C0`. - **Highlight:** `#C7F36B` — sparse proof or status accent, never a primary CTA. - **Success:** `#208A55`; warning `#B66A09`; error `#C23B32`; info `#2563A9`. -- **Dark product surfaces:** Near-black canvas with charcoal cards. Orange stays saturated because it is the only brand action color; purple is limited to Canvas graph semantics. Pricing keeps PromptWise's commercial hierarchy while using the same dark product vocabulary as Studio and Canvas. +- **Dark product surfaces:** Near-black canvas with charcoal cards. Orange stays saturated because it is the only brand action color; cold blue is limited to Canvas graph semantics. Pricing keeps PromptWise's commercial hierarchy while using the same dark product vocabulary as Studio, Canvas, and Assets. ## Spacing and Shape @@ -52,9 +52,10 @@ ## Layout - **Marketing grid:** 12 columns, max width 1280px, 24–32px gutters. -- **App grid:** Creative project routes use a 56px top bar and fluid main region without a persistent sidebar. Administrative routes may keep their own navigation. -- **Studio:** No sidebar. A full-bleed media wall supports a bottom floating composer; media type, model, ratio, credits, and Generate live in that composer. Content max width is 1380px and composer max width is 1180px. +- **App grid:** Creative project routes use a 56px top bar and fluid main region without a persistent sidebar. +- **Studio:** No sidebar. A full-bleed media wall supports a bottom floating composer; media type, model, ratio, parameters, and Generate live in that composer. Content max width is 1380px and composer max width is 1180px. - **Canvas:** Full-bleed workspace inside the same AppShell. Floating toolbars use the shared panel tokens. +- **Assets:** A project-scoped media library shares the same AppShell and content width as Studio. - **Responsive:** Sidebar becomes a drawer below 960px. Canvas remains usable with touch-safe controls and a collapsible inspector. ## Component Language @@ -69,11 +70,11 @@ MarketingHeader, HeroStatement, HeroDemoFrame, ProofStrip, FeatureStory, MediaSh ### Product -AppShell, AppSidebar, AppTopbar, PageHeader, MetricCard, DataTable, EmptyState, ErrorState, LoadingState, UsageMeter, CreditBalance, and StatusBadge. +AppShell, AppTopbar, PageHeader, MediaLibrary, EmptyState, ErrorState, LoadingState, and StatusBadge. ### Creative workspace -WorkspaceSwitcher, PromptComposer, MediaUploader, ModelPicker, EffectPicker, GenerationSettings, GenerationCard, TaskProgress, ResultViewer, CreditEstimate, APIRequestPreview, WebhookStatus, CanvasNode, CanvasEdge, CanvasToolbar, and InspectorPanel. +WorkspaceSwitcher, PromptComposer, MediaUploader, ModelPicker, EffectPicker, GenerationSettings, GenerationCard, GenerationHistory, TaskProgress, ResultViewer, CanvasNode, CanvasEdge, CanvasToolbar, and InspectorPanel. Do not create a universal `Section` component controlled by many presentation props. Reuse small primitives and keep each product section semantically owned. @@ -83,7 +84,7 @@ React Flow remains the interaction engine. PromptWise changes its visual express - Canvas background uses the same near-black canvas with a restrained two-scale dot grid. - Nodes are dark, thin-bordered cards with a narrow semantic header and clear input/output ports. -- Purple marks graph selection and connection semantics; orange remains reserved for brand and purchase actions. +- Cold blue marks graph selection and connection semantics; orange remains reserved for brand actions. - Toolbars and inspectors match AppShell surfaces, typography, radius, and focus states. - Zoom, pan, connect, multi-select, keyboard shortcuts, undo/redo, persistence, and accessibility behavior are regression-tested independently of styling. @@ -116,7 +117,7 @@ Homepage emphasis is approximately 70% what can be built with BeatAPI, 20% what | Date | Decision | Rationale | | --- | --- | --- | | 2026-08-12 | One PromptWise-derived design language | A single recognizable system is more valuable than several shallow themes. | -| 2026-08-12 | Shared AppShell with Studio and Canvas modes | Preserves both workflows without doubling dashboard maintenance. | +| 2026-08-12 | Shared AppShell with Studio, Canvas, and Assets modes | Preserves every project view without duplicating workspace chrome. | | 2026-08-12 | Orange primary, cream marketing, neutral app | Connects the brand across marketing and product while preserving workspace clarity. | | 2026-08-12 | Figtree + Geist Mono | Keeps creative brand expression and developer readability in one family of interfaces. | | 2026-08-15 | Unified black Studio, Canvas, and Pricing | Pricing retains the preferred PromptWise structure while matching the cinematic product environment. | diff --git a/README.md b/README.md index 667ea8a..3794816 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ There is no login, account system, payment flow, subscription, local credit ledg - Studio for prompt-first image and video generation. - React Flow Canvas for connected, multi-step creative workflows. +- A project-scoped Assets view shared by Studio and Canvas. - Local projects, snapshots, generation history, and asset indexing. - One code-defined catalog of supported BeatAPI image and video models. - BeatAPI task submission and status polling. @@ -37,7 +38,7 @@ Open `http://localhost:3020`. Home is the default route. Opening Studio or Canva ## How data flows ```text -Studio / Canvas +Studio / Canvas / Assets -> local server routes -> imported files saved under data/project-assets and indexed in SQLite -> generation precheck and a one-time SQLite generation intent diff --git a/README.zh-CN.md b/README.zh-CN.md index b963e22..d40df63 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,6 +10,7 @@ - Studio:以提示词和参数为主的快速生成体验。 - Canvas:基于 React Flow 的多步骤节点画布。 +- Assets:Studio 与 Canvas 共用的项目素材库。 - 本地项目、画布快照、生成历史和素材索引。 - 由代码统一维护的 BeatAPI 图片/视频模型目录。 - BeatAPI 任务提交、状态轮询和结果回填。 @@ -35,7 +36,7 @@ pnpm dev ## 数据逻辑 ```text -Studio / Canvas +Studio / Canvas / Assets -> 本地服务端接口 -> 导入文件立即保存到 data/project-assets,并写入 SQLite 素材索引 -> 生成预检与 SQLite 一次性生成意图 diff --git a/WORKSPACE_MODES.md b/WORKSPACE_MODES.md index 4f60fc9..f0ee179 100644 --- a/WORKSPACE_MODES.md +++ b/WORKSPACE_MODES.md @@ -1,13 +1,15 @@ # Workspace modes -Each project can be opened in either mode: +Each project can be opened in three modes: - **Studio** is a guided, form-first surface for producing a result quickly. - **Canvas** is a node-based surface for connected references and multi-step workflows. +- **Assets** is the project-scoped library shared by Studio and Canvas. -Both modes share the same project ID, assets, model catalog, provider connection, generation history, and persistence layer. The last opened mode is stored on the project so the project list can resume in place. +All three modes share the same project ID, assets, model catalog, provider connection, generation history, and persistence layer. The last opened mode is stored on the project so the project list can resume in place. Routes: - `/studio/:projectId` - `/canvas/:projectId` +- `/assets/:projectId` diff --git a/messages/en.json b/messages/en.json index 935ea6b..dbfa7be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "product.shell.home": "Home", + "product.shell.create": "Create", "product.shell.studio": "Studio", "product.shell.canvas": "Canvas", "product.shell.pricing": "Pricing", @@ -21,10 +22,10 @@ "product.home.createInStudio": "Create in Studio", "product.home.sectionTitle": "Choose your workspace", "product.home.viewProjects": "View projects", - "product.home.studioDescription": "Generate and compare images or videos in a focused workspace.", - "product.home.canvasDescription": "Arrange shared assets and connect them into a visual workflow.", + "product.home.studioDescription": "Generate images and videos in one workspace.", + "product.home.canvasDescription": "Connect assets in a visual workflow.", "product.home.projectsTitle": "Projects", - "product.home.projectsDescription": "Continue recent work with Studio and Canvas state kept together.", + "product.home.projectsDescription": "Continue recent Studio and Canvas work.", "product.home.openWorkspace": "Open workspace", "product.home.mediaImage": "Image", "product.home.mediaVideo": "Video", @@ -387,7 +388,8 @@ "status": { "queued": "Queued", "generating": "Generating", - "ready": "Ready", + "ready": "Success", + "success": "Success", "failed": "Failed" }, "fromCanvas": "From canvas", @@ -432,6 +434,23 @@ "freeGenerate": "Free generation", "uploadStart": "Upload image" }, + "feed": { + "history": "History", + "result": "Generation", + "generating": "Generating...", + "failed": "Failed", + "ready": "Ready", + "success": "Success", + "noPrompt": "No prompt", + "openPreview": "Open preview", + "closePreview": "Close preview", + "prompt": "Prompt", + "model": "Model", + "params": "Parameters", + "image": "Image", + "video": "Video", + "references": "References" + }, "textNote": { "template": "Text note\n\nCapture composition ideas, shot notes, or production reminders here." }, @@ -624,10 +643,9 @@ "retry": "Try again", "backToHistory": "Back to history", "startTitle": "Start a new project", - "startStudioDescription": "A Studio project is created only after you choose to start.", - "startCanvasDescription": "A Canvas project is created only after you choose to start.", - "startStudio": "Start Studio project", - "startCanvas": "Start Canvas project", + "startDescription": "Choose Studio or Canvas to create this project.", + "startStudio": "Studio", + "startCanvas": "Canvas", "defaultName": "Untitled project" } }, diff --git a/messages/zh.json b/messages/zh.json index fb0c565..bd1b53c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "product.shell.home": "首页", + "product.shell.create": "创作", "product.shell.studio": "Studio", "product.shell.canvas": "Canvas", "product.shell.pricing": "定价", @@ -22,10 +23,10 @@ "product.home.createInStudio": "在 Studio 中创作", "product.home.sectionTitle": "选择你的工作空间", "product.home.viewProjects": "查看项目", - "product.home.studioDescription": "在专注的工作区中生成和比较图片或视频。", - "product.home.canvasDescription": "排列共享素材,并把它们连接成可视化工作流。", + "product.home.studioDescription": "在专注工作区生成图片和视频。", + "product.home.canvasDescription": "把素材连成可视化工作流。", "product.home.projectsTitle": "项目", - "product.home.projectsDescription": "继续最近的创作,Studio 与 Canvas 状态会保存在同一个项目中。", + "product.home.projectsDescription": "继续最近的 Studio 与 Canvas 创作。", "product.home.openWorkspace": "打开工作空间", "product.home.mediaImage": "图片", "product.home.mediaVideo": "视频", @@ -389,6 +390,7 @@ "queued": "排队中", "generating": "生成中", "ready": "已就绪", + "success": "成功", "failed": "失败" }, "fromCanvas": "来自画布", @@ -433,6 +435,23 @@ "freeGenerate": "自由生成", "uploadStart": "上传图片" }, + "feed": { + "history": "历史", + "result": "生成结果", + "generating": "生成中...", + "failed": "失败", + "ready": "成功", + "success": "成功", + "noPrompt": "无提示词", + "openPreview": "打开预览", + "closePreview": "关闭预览", + "prompt": "提示词", + "model": "模型", + "params": "参数", + "image": "图片", + "video": "视频", + "references": "参考图" + }, "textNote": { "template": "文本备注\n\n在这里记录构图想法、镜头说明或制作备注。" }, @@ -625,10 +644,9 @@ "retry": "重试", "backToHistory": "返回历史", "startTitle": "开始新项目", - "startStudioDescription": "只有确认开始后,才会创建 Studio 项目。", - "startCanvasDescription": "只有确认开始后,才会创建 Canvas 项目。", - "startStudio": "开始 Studio 项目", - "startCanvas": "开始 Canvas 项目", + "startDescription": "选择 Studio 或 Canvas 来创建这个项目。", + "startStudio": "Studio", + "startCanvas": "Canvas", "defaultName": "未命名项目" } }, diff --git a/src/components/app/create-project-route-page.test.ts b/src/components/app/create-project-route-page.test.ts index fb0c2d6..6ba4749 100644 --- a/src/components/app/create-project-route-page.test.ts +++ b/src/components/app/create-project-route-page.test.ts @@ -9,7 +9,9 @@ const source = readFileSync( test('visiting Studio or Canvas does not create a project automatically', () => { assert.doesNotMatch(source, /useEffect/); - assert.match(source, /onClick=\{\(\) => void createProject\(\)\}/); + assert.match(source, /createProject\('studio'\)/); + assert.match(source, /createProject\('canvas'\)/); + assert.match(source, /envConfigs\.app_logo/); assert.match(source, /'\/api\/app\/projects'/); }); diff --git a/src/components/app/create-project-route-page.tsx b/src/components/app/create-project-route-page.tsx index e7ea2df..c94ba00 100644 --- a/src/components/app/create-project-route-page.tsx +++ b/src/components/app/create-project-route-page.tsx @@ -1,15 +1,10 @@ import { useState } from 'react'; -import { - AlertCircleIcon, - LayoutPanelTopIcon, - Loader2Icon, - WorkflowIcon, -} from 'lucide-react'; +import { AlertCircleIcon, Loader2Icon } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { envConfigs } from '@/config'; import type { WorkspaceMode } from '@/config/workspace-modes'; import { buildPostCreateProjectDetailPath } from '@/core/projects/project-entry'; -import { useLocaleRouter } from '@/core/i18n/navigation'; import { apiJsonPost } from '@/lib/api-client'; import { m } from '@/paraglide/messages.js'; @@ -41,13 +36,8 @@ function getCopy(locale: string) { { locale: messageLocale } ), retry: m['BeatAPI.project.retry']({}, { locale: messageLocale }), - back: m['BeatAPI.project.backToHistory']({}, { locale: messageLocale }), startTitle: m['BeatAPI.project.startTitle']({}, { locale: messageLocale }), - startStudioDescription: m['BeatAPI.project.startStudioDescription']( - {}, - { locale: messageLocale } - ), - startCanvasDescription: m['BeatAPI.project.startCanvasDescription']( + startDescription: m['BeatAPI.project.startDescription']( {}, { locale: messageLocale } ), @@ -67,20 +57,12 @@ export function CreateProjectRoutePage({ }: { data: CreateProjectRouteData; }) { - const { - locale, - target, - model, - prompt, - workspaceMode, - projectName, - } = data; - const router = useLocaleRouter(); + const { locale, target, model, prompt, projectName } = data; const copy = getCopy(locale); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); - async function createProject() { + async function createProject(workspaceMode: WorkspaceMode) { if (creating) return; setCreating(true); setError(null); @@ -113,13 +95,6 @@ export function CreateProjectRoutePage({ } } - const isStudio = workspaceMode === 'studio'; - const description = isStudio - ? copy.startStudioDescription - : copy.startCanvasDescription; - const startLabel = isStudio ? copy.startStudio : copy.startCanvas; - const WorkspaceIcon = isStudio ? LayoutPanelTopIcon : WorkflowIcon; - return (
@@ -132,35 +107,49 @@ export function CreateProjectRoutePage({ ) : ( -
- -
+ {envConfigs.app_name} )} -

+

{error ? copy.errorTitle : creating ? copy.title : copy.startTitle}

- {error || (creating ? copy.description : description)} + {error || (creating ? copy.description : copy.startDescription)}

{!creating ? (
- - + {error ? ( + + ) : ( + <> + + + + )}
) : null}
diff --git a/src/components/app/product-page-shell-title.test.ts b/src/components/app/product-page-shell-title.test.ts index 3707d53..1e2309c 100644 --- a/src/components/app/product-page-shell-title.test.ts +++ b/src/components/app/product-page-shell-title.test.ts @@ -9,6 +9,7 @@ test('project title field uses the available header width instead of a short cha ); assert.doesNotMatch(source, /20\)\}ch/); + assert.doesNotMatch(source, /maxLength=/); assert.match(source, /className="flex min-w-0 flex-1 items-center gap-3"/); assert.match(source, /className="min-w-0 flex-1"/); assert.match(source, /className="h-9 w-full/); diff --git a/src/components/app/product-page-shell.test.ts b/src/components/app/product-page-shell.test.ts index f7b0d46..816ff40 100644 --- a/src/components/app/product-page-shell.test.ts +++ b/src/components/app/product-page-shell.test.ts @@ -18,7 +18,8 @@ const apiConfigSource = readFileSync( test('workspace header keeps mode switching, shared assets, and API configuration actions', () => { assert.match(shellSource, /WorkspaceApiConfigDialog/); - assert.match(shellSource, /ProjectAssetsDialog/); + assert.match(shellSource, /\/assets\//); + assert.match(shellSource, />Assets<\/span>/); assert.match(shellSource, /GitHubIcon/); assert.match( shellSource, diff --git a/src/components/app/product-page-shell.tsx b/src/components/app/product-page-shell.tsx index 91f922f..6be0669 100644 --- a/src/components/app/product-page-shell.tsx +++ b/src/components/app/product-page-shell.tsx @@ -2,10 +2,9 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { LayoutPanelTop, Workflow } from 'lucide-react'; +import { Images, LayoutPanelTop, Workflow } from 'lucide-react'; import { WorkspaceApiConfigDialog } from '@/components/app/workspace-api-config-dialog'; -import { ProjectAssetsDialog } from '@/components/app/project-assets-dialog'; import { GitHubIcon } from '@/components/icons/github'; import { Link } from '@/core/i18n/navigation'; import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; @@ -99,7 +98,7 @@ export function ProductPageShell({ } }} aria-label={t('header.workspaceNameLabel')} - className="h-9 w-full min-w-0 rounded-md border-0 bg-transparent px-0 text-[15px] font-semibold text-[var(--beat-text-1)] outline-none placeholder:text-white/35 focus:ring-0" + className="h-9 w-full min-w-0 truncate rounded-md border-0 bg-transparent px-0 text-[15px] font-semibold text-[var(--beat-text-1)] outline-none placeholder:text-white/35 focus:ring-0" /> ) : ( @@ -154,14 +153,19 @@ export function ProductPageShell({ > Canvas + + Assets + ) : null} - {projectId ? ( - - ) : null} diff --git a/src/components/app/project-assets-dialog.tsx b/src/components/app/project-assets-dialog.tsx index 7313813..2ff983d 100644 --- a/src/components/app/project-assets-dialog.tsx +++ b/src/components/app/project-assets-dialog.tsx @@ -26,6 +26,7 @@ import { } from '@/core/workspace-lib/app/workspace-client-api'; import { recentAssetsKeys } from '@/core/workspace-lib/app/workspace-query-keys'; import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; +import { beatPanelLabelClassName } from '@/components/app/composer-styles'; import { PROJECT_ASSET_DRAG_MIME, PROJECT_ASSET_INSERT_EVENT, @@ -83,7 +84,7 @@ function AssetTile({ onDragEnd={(event) => { if (event.dataTransfer.dropEffect === 'copy') onAdded(); }} - className={`group relative aspect-square overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#09090b] transition hover:-translate-y-0.5 hover:border-white/[0.22] ${ + className={`group relative h-[200px] w-fit overflow-hidden rounded-[14px] bg-transparent transition hover:-translate-y-0.5 ${ canAddToCanvas ? 'cursor-grab active:cursor-grabbing' : '' }`} title={asset.filename || addLabel} @@ -94,13 +95,13 @@ function AssetTile({ muted playsInline preload="metadata" - className="size-full object-cover opacity-85 transition duration-300 group-hover:opacity-100" + className="h-full w-auto object-contain" /> ) : ( {asset.filename )} {mediaType === 'video' ? ( @@ -148,15 +149,16 @@ function AssetTile({ ); } -export function ProjectAssetsDialog({ +export function ProjectAssetsLibrary({ projectId, canAddToCanvas = false, + onAdded, }: { projectId: string; canAddToCanvas?: boolean; + onAdded?: () => void; }) { const t = useTranslations('AppShell.header.projectAssets'); - const [open, setOpen] = useState(false); const assetsQuery = useQuery({ queryKey: recentAssetsKeys.lists(projectId), queryFn: () => fetchRecentAssets(projectId), @@ -166,6 +168,93 @@ export function ProjectAssetsDialog({ const videos = assetsQuery.data?.videos ?? []; const total = images.length + videos.length; + return ( + <> + {assetsQuery.isLoading ? ( +
+ + {t('loading')} +
+ ) : null} + + {assetsQuery.isError ? ( +
+ {t('loadFailed')} +
+ ) : null} + + {!assetsQuery.isLoading && !assetsQuery.isError && total === 0 ? ( +
+ + + +

+ {t('empty')} +

+
+ ) : null} + + {!assetsQuery.isLoading && !assetsQuery.isError && total > 0 ? ( +
+ {images.length > 0 ? ( +
+

+ {t('images')} +

+
+ {images.map((asset) => ( + onAdded?.()} + addLabel={t('addToCanvas')} + openLabel={t('openOriginal')} + /> + ))} +
+
+ ) : null} + + {videos.length > 0 ? ( +
+

+ {t('videos')} +

+
+ {videos.map((asset) => ( + onAdded?.()} + addLabel={t('addToCanvas')} + openLabel={t('openOriginal')} + /> + ))} +
+
+ ) : null} +
+ ) : null} + + ); +} + +export function ProjectAssetsDialog({ + projectId, + canAddToCanvas = false, +}: { + projectId: string; + canAddToCanvas?: boolean; +}) { + const t = useTranslations('AppShell.header.projectAssets'); + const [open, setOpen] = useState(false); + return ( {t('triggerLabel')}
- {total > 0 ? ( - - {total} - - ) : null}
- {assetsQuery.isLoading ? ( -
- - {t('loading')} -
- ) : null} - - {assetsQuery.isError ? ( -
- {t('loadFailed')} -
- ) : null} - - {!assetsQuery.isLoading && !assetsQuery.isError && total === 0 ? ( -
- - - -

- {t('empty')} -

-
- ) : null} - - {!assetsQuery.isLoading && !assetsQuery.isError && total > 0 ? ( -
- {images.length > 0 ? ( -
-

- {t('images')} -

-
- {images.map((asset) => ( - setOpen(false)} - addLabel={t('addToCanvas')} - openLabel={t('openOriginal')} - /> - ))} -
-
- ) : null} - - {videos.length > 0 ? ( -
-

- {t('videos')} -

-
- {videos.map((asset) => ( - setOpen(false)} - addLabel={t('addToCanvas')} - openLabel={t('openOriginal')} - /> - ))} -
-
- ) : null} -
- ) : null} + setOpen(false)} + />
diff --git a/src/components/app/project-assets-workspace.tsx b/src/components/app/project-assets-workspace.tsx new file mode 100644 index 0000000..e74cc75 --- /dev/null +++ b/src/components/app/project-assets-workspace.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { ProjectAssetsLibrary } from '@/components/app/project-assets-dialog'; +import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; + +export function ProjectAssetsWorkspace({ projectId }: { projectId: string }) { + const t = useTranslations('AppShell.header.projectAssets'); + + return ( +
+
+
+
+

+ {t('title')} +

+

+ {t('description')} +

+
+ +
+
+
+
+ ); +} diff --git a/src/components/app/workspace-project-hub.tsx b/src/components/app/workspace-project-hub.tsx index 99b4ce0..e005f71 100644 --- a/src/components/app/workspace-project-hub.tsx +++ b/src/components/app/workspace-project-hub.tsx @@ -17,7 +17,7 @@ import { startTransition, useDeferredValue, useMemo, useState } from 'react'; export type WorkspaceProjectCardItem = { id: string; name: string; - lastWorkspaceMode?: 'studio' | 'canvas'; + lastWorkspaceMode?: 'studio' | 'canvas' | 'assets'; activityAt: string; activityLabel: string; coverImageUrl?: string | null; diff --git a/src/components/app/workspace-project-route-page.tsx b/src/components/app/workspace-project-route-page.tsx index 0c7257a..b25b5e2 100644 --- a/src/components/app/workspace-project-route-page.tsx +++ b/src/components/app/workspace-project-route-page.tsx @@ -1,4 +1,5 @@ import { ProductPageShell } from '@/components/app/product-page-shell'; +import { ProjectAssetsWorkspace } from '@/components/app/project-assets-workspace'; import { BeatStudioWorkspace } from '@/components/studio/beat-studio-workspace'; import { BeatCanvasShell } from '@/components/beatcanvas/beatcanvas-shell'; import type { loadWorkspaceProjectRoute } from '@/core/projects/workspace-project-route-loader'; @@ -25,6 +26,8 @@ export function WorkspaceProjectRoutePage({ initialModelId={data.modelId} initialPrompt={data.prompt} /> + ) : data.workspaceMode === 'assets' ? ( + ) : ( ({ - imageTitle: studioT('canvas.frame.imageTitle'), - videoTitle: studioT('canvas.frame.videoTitle'), - imageModeLabel: studioT('canvas.composer.imageMode'), - videoModeLabel: studioT('canvas.composer.videoMode'), - createGenerationCardLabel: studioT('canvas.connector.createGeneration'), - createImageGenerationCardLabel: studioT( - 'canvas.connector.createImageGeneration' - ), - createVideoGenerationCardLabel: studioT( - 'canvas.connector.createVideoGeneration' - ), - connectorUploadLabel: studioT('canvas.connector.upload'), - imagePromptPlaceholder: studioT('canvas.frame.imagePromptPlaceholder'), - videoPromptPlaceholder: studioT('canvas.frame.videoPromptPlaceholder'), - zoomLabel: studioT('canvas.zoom.label'), - zoomOutLabel: studioT('canvas.zoom.zoomOut'), - zoomInLabel: studioT('canvas.zoom.zoomIn'), - selectToolLabel: studioT('canvas.zoom.select'), - panToolLabel: studioT('canvas.zoom.pan'), - fitViewLabel: studioT('canvas.zoom.fitView'), - hideEdgesLabel: studioT('canvas.zoom.hideEdges'), - showEdgesLabel: studioT('canvas.zoom.showEdges'), - snapToGridLabel: studioT('canvas.zoom.snapToGrid'), - undoLabel: studioT('canvas.zoom.undo'), - redoLabel: studioT('canvas.zoom.redo'), - historyLabel: studioT('canvas.shapes.history'), - latestResultLabel: studioT('canvas.shapes.latestResult'), - emptyStateTitle: studioT('emptyState.title'), - emptyStateDescription: studioT('emptyState.description'), - emptyGuideTitle: studioT('emptyState.guideTitle'), - emptyGuideDescription: studioT('emptyState.guideDescription'), - emptyFreeGenerateLabel: studioT('emptyState.freeGenerate'), - emptyUploadStartLabel: studioT('emptyState.uploadStart'), - typeLabel: studioT('canvas.composer.type'), - modelLabel: studioT('single.labels.model'), - parameterLabel: studioT('canvas.composer.parameters'), - aspectRatioLabel: studioT('single.labels.aspectRatio'), - outputQualityLabel: studioT('single.labels.outputQuality'), - durationLabel: studioT('single.labels.duration'), - characterOrientationLabel: studioT( - 'canvas.composer.characterOrientation' - ), - backgroundSourceLabel: studioT('canvas.composer.backgroundSource'), - languageLabel: studioT('canvas.composer.language'), - uploadImageLabel: studioT('actions.uploadImage'), - uploadVideoLabel: studioT('actions.uploadVideo'), - fromCanvasLabel: studioT('canvas.composer.fromCanvas'), - currentReferencesLabel: studioT('canvas.composer.currentReferences'), - noCanvasReferencesLabel: studioT('canvas.composer.noCanvasReferences'), - removeReferenceLabel: studioT('canvas.composer.removeReference'), - generateLabel: studioT('canvas.composer.generate'), - regenerateLabel: studioT('canvas.composer.regenerate'), - generatingLabel: studioT('canvas.composer.generating'), - closeComposerLabel: studioT('canvas.composer.close'), - defaultSetupLabel: studioT('canvas.composer.defaultSetup'), - modeOptionLabel: studioT('canvas.composer.mode'), - variantOptionLabel: studioT('canvas.composer.variant'), - qualityOptionLabel: studioT('canvas.composer.quality'), - tokenQualityLabel: studioT('canvas.composer.tokens.quality'), - tokenFastLabel: studioT('canvas.composer.tokens.fast'), - tokenLiteLabel: studioT('canvas.composer.tokens.lite'), - tokenLowLabel: studioT('canvas.composer.tokens.low'), - tokenMediumLabel: studioT('canvas.composer.tokens.medium'), - tokenStandardLabel: studioT('canvas.composer.tokens.standard'), - tokenHighLabel: studioT('canvas.composer.tokens.high'), - tokenProLabel: studioT('canvas.composer.tokens.pro'), - tokenAdaptiveLabel: studioT('canvas.composer.tokens.adaptive'), - tokenAutoLabel: studioT('canvas.composer.tokens.auto'), - tokenLandscapeLabel: studioT('canvas.composer.tokens.landscape'), - tokenPortraitLabel: studioT('canvas.composer.tokens.portrait'), - tokenChineseLabel: studioT('canvas.composer.tokens.chinese'), - tokenEnglishLabel: studioT('canvas.composer.tokens.english'), - tokenImageOrientationLabel: studioT( - 'canvas.composer.tokens.imageOrientation' - ), - tokenVideoOrientationLabel: studioT( - 'canvas.composer.tokens.videoOrientation' - ), - tokenInputImageLabel: studioT('canvas.composer.tokens.inputImage'), - tokenInputVideoLabel: studioT('canvas.composer.tokens.inputVideo'), - queuedStatusLabel: studioT('canvas.composer.status.queued'), - generatingStatusLabel: studioT('canvas.composer.status.generating'), - readyStatusLabel: studioT('canvas.composer.status.ready'), - failedStatusLabel: studioT('canvas.composer.status.failed'), - }), - [studioT] - ); + const canvasCopy = useCanvasComposerLabels(); const canvasComponents = useMemo( () => ({ diff --git a/src/components/beatcanvas/use-canvas-composer-labels.ts b/src/components/beatcanvas/use-canvas-composer-labels.ts new file mode 100644 index 0000000..c28664f --- /dev/null +++ b/src/components/beatcanvas/use-canvas-composer-labels.ts @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; + +import { useTranslations } from '@/core/workspace-lib/shims/next-intl'; + +import type { CanvasLabels } from './beatcanvas-front-layer-context'; + +export function useCanvasComposerLabels(): CanvasLabels { + const studioT = useTranslations('AppShell.studio'); + + return useMemo( + () => ({ + imageTitle: studioT('canvas.frame.imageTitle'), + videoTitle: studioT('canvas.frame.videoTitle'), + imageModeLabel: studioT('canvas.composer.imageMode'), + videoModeLabel: studioT('canvas.composer.videoMode'), + createGenerationCardLabel: studioT('canvas.connector.createGeneration'), + createImageGenerationCardLabel: studioT( + 'canvas.connector.createImageGeneration' + ), + createVideoGenerationCardLabel: studioT( + 'canvas.connector.createVideoGeneration' + ), + connectorUploadLabel: studioT('canvas.connector.upload'), + imagePromptPlaceholder: studioT('canvas.frame.imagePromptPlaceholder'), + videoPromptPlaceholder: studioT('canvas.frame.videoPromptPlaceholder'), + zoomLabel: studioT('canvas.zoom.label'), + zoomOutLabel: studioT('canvas.zoom.zoomOut'), + zoomInLabel: studioT('canvas.zoom.zoomIn'), + selectToolLabel: studioT('canvas.zoom.select'), + panToolLabel: studioT('canvas.zoom.pan'), + fitViewLabel: studioT('canvas.zoom.fitView'), + hideEdgesLabel: studioT('canvas.zoom.hideEdges'), + showEdgesLabel: studioT('canvas.zoom.showEdges'), + snapToGridLabel: studioT('canvas.zoom.snapToGrid'), + undoLabel: studioT('canvas.zoom.undo'), + redoLabel: studioT('canvas.zoom.redo'), + historyLabel: studioT('canvas.shapes.history'), + latestResultLabel: studioT('canvas.shapes.latestResult'), + emptyStateTitle: studioT('emptyState.title'), + emptyStateDescription: studioT('emptyState.description'), + emptyGuideTitle: studioT('emptyState.guideTitle'), + emptyGuideDescription: studioT('emptyState.guideDescription'), + emptyFreeGenerateLabel: studioT('emptyState.freeGenerate'), + emptyUploadStartLabel: studioT('emptyState.uploadStart'), + typeLabel: studioT('canvas.composer.type'), + modelLabel: studioT('single.labels.model'), + parameterLabel: studioT('canvas.composer.parameters'), + aspectRatioLabel: studioT('single.labels.aspectRatio'), + outputQualityLabel: studioT('single.labels.outputQuality'), + durationLabel: studioT('single.labels.duration'), + characterOrientationLabel: studioT( + 'canvas.composer.characterOrientation' + ), + backgroundSourceLabel: studioT('canvas.composer.backgroundSource'), + languageLabel: studioT('canvas.composer.language'), + uploadImageLabel: studioT('actions.uploadImage'), + uploadVideoLabel: studioT('actions.uploadVideo'), + fromCanvasLabel: studioT('canvas.composer.fromCanvas'), + currentReferencesLabel: studioT('canvas.composer.currentReferences'), + noCanvasReferencesLabel: studioT('canvas.composer.noCanvasReferences'), + removeReferenceLabel: studioT('canvas.composer.removeReference'), + generateLabel: studioT('canvas.composer.generate'), + regenerateLabel: studioT('canvas.composer.regenerate'), + generatingLabel: studioT('canvas.composer.generating'), + closeComposerLabel: studioT('canvas.composer.close'), + defaultSetupLabel: studioT('canvas.composer.defaultSetup'), + modeOptionLabel: studioT('canvas.composer.mode'), + variantOptionLabel: studioT('canvas.composer.variant'), + qualityOptionLabel: studioT('canvas.composer.quality'), + tokenQualityLabel: studioT('canvas.composer.tokens.quality'), + tokenFastLabel: studioT('canvas.composer.tokens.fast'), + tokenLiteLabel: studioT('canvas.composer.tokens.lite'), + tokenLowLabel: studioT('canvas.composer.tokens.low'), + tokenMediumLabel: studioT('canvas.composer.tokens.medium'), + tokenStandardLabel: studioT('canvas.composer.tokens.standard'), + tokenHighLabel: studioT('canvas.composer.tokens.high'), + tokenProLabel: studioT('canvas.composer.tokens.pro'), + tokenAdaptiveLabel: studioT('canvas.composer.tokens.adaptive'), + tokenAutoLabel: studioT('canvas.composer.tokens.auto'), + tokenLandscapeLabel: studioT('canvas.composer.tokens.landscape'), + tokenPortraitLabel: studioT('canvas.composer.tokens.portrait'), + tokenChineseLabel: studioT('canvas.composer.tokens.chinese'), + tokenEnglishLabel: studioT('canvas.composer.tokens.english'), + tokenImageOrientationLabel: studioT( + 'canvas.composer.tokens.imageOrientation' + ), + tokenVideoOrientationLabel: studioT( + 'canvas.composer.tokens.videoOrientation' + ), + tokenInputImageLabel: studioT('canvas.composer.tokens.inputImage'), + tokenInputVideoLabel: studioT('canvas.composer.tokens.inputVideo'), + queuedStatusLabel: studioT('canvas.composer.status.queued'), + generatingStatusLabel: studioT('canvas.composer.status.generating'), + readyStatusLabel: studioT('canvas.composer.status.ready'), + failedStatusLabel: studioT('canvas.composer.status.failed'), + }), + [studioT] + ); +} diff --git a/src/components/home/workspace-page.tsx b/src/components/home/workspace-page.tsx index dbdc777..cc92abd 100644 --- a/src/components/home/workspace-page.tsx +++ b/src/components/home/workspace-page.tsx @@ -26,7 +26,7 @@ import { m } from '@/paraglide/messages.js'; type WorkspaceProject = { id: string; name: string; - lastWorkspaceMode?: 'studio' | 'canvas'; + lastWorkspaceMode?: 'studio' | 'canvas' | 'assets'; activityAt: string; activityLabel: string; coverImageUrl?: string | null; diff --git a/src/components/marketing/beatapi-product-home.tsx b/src/components/marketing/beatapi-product-home.tsx index 4d572d6..1967182 100644 --- a/src/components/marketing/beatapi-product-home.tsx +++ b/src/components/marketing/beatapi-product-home.tsx @@ -1,9 +1,7 @@ import { ArrowRight, ArrowUp, - Film, FolderClock, - ImagePlus, PanelsTopLeft, Workflow, } from 'lucide-react'; @@ -39,12 +37,7 @@ function getCopy(locale: string) { { locale: messageLocale } ), open: m['product.home.openWorkspace']({}, { locale: messageLocale }), - createInStudio: m['product.home.createInStudio']( - {}, - { locale: messageLocale } - ), - mediaImage: m['product.home.mediaImage']({}, { locale: messageLocale }), - mediaVideo: m['product.home.mediaVideo']({}, { locale: messageLocale }), + create: m['product.shell.create']({}, { locale: messageLocale }), }; } @@ -79,7 +72,7 @@ function WorkspaceCard({

{title}

-

+

{description}

@@ -93,10 +86,6 @@ function WorkspaceCard({ function PromptComposer({ copy }: { copy: HomeCopy }) { const [prompt, setPrompt] = useState(''); - const mediaActions = [ - { label: copy.mediaImage, icon: ImagePlus }, - { label: copy.mediaVideo, icon: Film }, - ] as const; return (
-
-
- {mediaActions.map(({ label, icon: Icon }) => ( - - ))} -
+
{open ? ( -
+
{options.map((option) => ( ))} diff --git a/src/components/studio/beat-studio-workspace.test.ts b/src/components/studio/beat-studio-workspace.test.ts index 1873fbb..ba199b1 100644 --- a/src/components/studio/beat-studio-workspace.test.ts +++ b/src/components/studio/beat-studio-workspace.test.ts @@ -6,6 +6,14 @@ const studioSource = readFileSync( new URL('./beat-studio-workspace.tsx', import.meta.url), 'utf8' ); +const composerSource = readFileSync( + new URL('./studio-composer.tsx', import.meta.url), + 'utf8' +); +const feedSource = readFileSync( + new URL('./studio-generation-feed.tsx', import.meta.url), + 'utf8' +); const startHereSource = readFileSync( new URL('./studio-start-here.tsx', import.meta.url), 'utf8' @@ -17,38 +25,29 @@ test('studio uses the shared black creative surface without a persistent media s assert.doesNotMatch(studioSource, /w-14 shrink-0 flex-col/); }); -test('studio mirrors the BeatAPI start state and composer rhythm', () => { - const composerIndex = studioSource.indexOf('max-w-[1138px]'); - const mediaSelectIndex = studioSource.indexOf( - 'ariaLabel="Media type"', - composerIndex - ); - - assert.ok(composerIndex >= 0); - assert.ok(mediaSelectIndex > composerIndex); +test('studio keeps the original wide composer and a project generation feed', () => { + assert.match(studioSource, /StudioComposer/); + assert.match(studioSource, /StudioGenerationFeed/); assert.match(studioSource, /StudioStartHere/); + assert.match(composerSource, /max-w-\[1138px\]/); + assert.match(composerSource, /ariaLabel="Media type"/); + assert.match(composerSource, /WorkspaceSelect/); + assert.match(composerSource, /BeatCanvasComposerParameterPicker/); + assert.match(composerSource, /truncatePromptToMaxChars/); + assert.match(composerSource, /labels\.regenerateLabel/); + assert.match(feedSource, /max-w-\[1138px\]/); + assert.match(feedSource, /formatStudioHistoryDateTime/); + assert.match(feedSource, /h-\[240px\]/); + assert.match(feedSource, /object-contain/); + assert.doesNotMatch(feedSource, /item\.prompt/); + assert.match(studioSource, /justify-end/); assert.match(startHereSource, /Create Here/); - assert.match( - startHereSource, - /Imagine the scene\. Shape the mood\. Bring it to life\./ - ); - assert.match(startHereSource, /pointer-events-auto/); - assert.match(startHereSource, /create-thumb-forest\.webp/); - assert.match(startHereSource, /aria-pressed/); - assert.match(startHereSource, /setSelectedSrc/); - assert.doesNotMatch(startHereSource, /onSelect/); - assert.doesNotMatch(studioSource, /selectStarterIdea/); - assert.match(studioSource, /data-beatapi-composer/); - assert.match(studioSource, /WorkspaceSelect/); + assert.match(studioSource, /fetchProjectGenerations/); }); test('studio model selector follows the selected model name width', () => { assert.match( - studioSource, + composerSource, /ariaLabel="Model"[\s\S]*?triggerClassName="w-fit max-w-full"/ ); - assert.doesNotMatch( - studioSource, - /ariaLabel="Model"[\s\S]*?triggerClassName="w-\[224px\]"/ - ); }); diff --git a/src/components/studio/beat-studio-workspace.tsx b/src/components/studio/beat-studio-workspace.tsx index c5686d8..e3c7b91 100644 --- a/src/components/studio/beat-studio-workspace.tsx +++ b/src/components/studio/beat-studio-workspace.tsx @@ -1,33 +1,32 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { - ArrowUp, - Film, - ImageIcon, - ImagePlus, - Loader2, - Sparkles, -} from 'lucide-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { resolveOutputMedia } from '@/core/effects/output-media'; +import { StudioComposer } from '@/components/studio/studio-composer'; +import { StudioGenerationFeed } from '@/components/studio/studio-generation-feed'; +import { StudioStartHere } from '@/components/studio/studio-start-here'; +import type { CanvasCardMediaType } from '@/core/beatcanvas/canvas-types'; import { resolveWmTaskId } from '@/core/effects/client-api'; -import { getModelIconPathByModelId } from '@/core/workspace-lib/model-icons'; -import { WorkspaceSelect } from '@/components/app/workspace-select'; +import { resolveOutputMedia } from '@/core/effects/output-media'; +import { generationValidationConstraints } from '@/core/effects/validation'; import { - composerCardClassName, - composerGenerateButtonClassName, -} from '@/components/app/composer-styles'; + findWorkspaceModelOption, + getDefaultSelectableWorkspaceModel, +} from '@/core/effects/workspace-models'; +import { + applyStudioDraftModel, + createStudioDraftCard, +} from '@/core/studio/studio-draft'; import { buildStudioEffectInput, getStudioModels, type StudioMedia, } from '@/core/studio/studio-runtime'; -import { apiJsonGet, apiJsonPost } from '@/lib/api-client'; +import { fetchProjectGenerations } from '@/core/workspace-lib/app/workspace-client-api'; import { invalidateWorkspaceAfterGeneration } from '@/core/workspace-lib/app/workspace-query-invalidation'; -import { StudioStartHere } from '@/components/studio/studio-start-here'; -import { cn } from '@/lib/utils'; +import { projectGenerationsKeys } from '@/core/workspace-lib/app/workspace-query-keys'; +import { apiJsonGet, apiJsonPost } from '@/lib/api-client'; type GenerationResponse = { status?: 'pending' | 'processing' | 'succeeded' | 'failed'; @@ -76,42 +75,67 @@ export function BeatStudioWorkspace({ const queryClient = useQueryClient(); const initialMedia: StudioMedia = initialTarget === 'video' ? 'video' : 'image'; - const [media, setMedia] = useState(initialMedia); - const [prompt, setPrompt] = useState(initialPrompt || ''); - const [aspectRatio, setAspectRatio] = useState( - initialMedia === 'video' ? '16:9' : '1:1' - ); - const [modelId, setModelId] = useState(initialModelId || ''); - const [resultUrl, setResultUrl] = useState(null); + const imageModels = useMemo(() => getStudioModels('image'), []); + const videoModels = useMemo(() => getStudioModels('video'), []); + const [draft, setDraft] = useState(() => { + const models = initialMedia === 'video' ? videoModels : imageModels; + const model = + findWorkspaceModelOption(models, initialModelId) ?? + getDefaultSelectableWorkspaceModel( + initialMedia === 'video' ? 'ai-video' : 'ai-image' + ); + return createStudioDraftCard({ + type: initialMedia, + model, + prompt: initialPrompt || '', + }); + }); const [error, setError] = useState(''); - const models = useMemo(() => getStudioModels(media), [media]); + const models = draft.type === 'video' ? videoModels : imageModels; const selectedModel = - models.find((model) => model.id === modelId) ?? models[0] ?? null; + findWorkspaceModelOption(models, draft.modelId) ?? models[0] ?? null; + + const generationsQuery = useQuery({ + queryKey: projectGenerationsKeys.list(projectId), + queryFn: () => fetchProjectGenerations(projectId), + refetchInterval: (query) => + query.state.data?.items.some( + (item) => item.status === 'pending' || item.status === 'processing' + ) + ? 2500 + : false, + }); + const feedItems = (generationsQuery.data?.items ?? []).filter( + (item) => item.status !== 'failed' + ); useEffect(() => { - if (!selectedModel) return; - if (selectedModel.id !== modelId) setModelId(selectedModel.id); - const ratios = selectedModel.supportedAspectRatios || []; - if (ratios.length && !ratios.includes(aspectRatio as never)) { - setAspectRatio(selectedModel.defaultAspectRatio || ratios[0]); - } - }, [aspectRatio, modelId, selectedModel]); + if (!selectedModel || selectedModel.id === draft.modelId) return; + setDraft((current) => + applyStudioDraftModel({ draft: current, model: selectedModel }) + ); + }, [draft.modelId, selectedModel]); const generation = useMutation({ mutationFn: async () => { if (!selectedModel) { throw new Error('No model is available for this media type.'); } - if (!prompt.trim()) { + if (!draft.prompt.trim()) { throw new Error('Describe what you want to create first.'); } const payload = { effectId: selectedModel.effectId, input: buildStudioEffectInput({ - media, + media: draft.type, model: selectedModel, - prompt, - aspectRatio, + prompt: draft.prompt, + aspectRatio: draft.aspectRatio, + duration: draft.duration, + outputQuality: draft.outputQuality, + mode: draft.mode, + quality: draft.quality, + language: draft.language, }), projectId, }; @@ -133,26 +157,25 @@ export function BeatStudioWorkspace({ if (created.status === 'failed') { throw new Error(created.error || 'Generation failed'); } - let output = created.output; + await invalidateWorkspaceAfterGeneration(queryClient); const wmTaskId = resolveWmTaskId(created); if (wmTaskId && created.status !== 'succeeded') { - output = await waitForGeneration({ + await waitForGeneration({ wmTaskId, effectId: selectedModel.effectId, }); - } - const mediaOutput = resolveOutputMedia(output); - if (!mediaOutput.resultUrl) { + } else if (!resolveOutputMedia(created.output).resultUrl) { throw new Error('Generation completed without a media URL.'); } - return mediaOutput.resultUrl; }, - onSuccess: (url) => { - setResultUrl(url); + onSuccess: () => { setError(''); void invalidateWorkspaceAfterGeneration(queryClient); }, - onError: (generationError: Error) => setError(generationError.message), + onError: (generationError: Error) => { + setError(generationError.message); + void invalidateWorkspaceAfterGeneration(queryClient); + }, }); return ( @@ -160,152 +183,56 @@ export function BeatStudioWorkspace({
-
- {resultUrl ? ( -
- {media === 'video' ? ( -