diff --git a/.env.example b/.env.example index 5ca98a5..e918428 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ EFFECTS_GENERATION_TIMEOUT_MS=1800000 WORKSPACE_STORAGE_MODE=beatapi # Official hosted deployments only: BeatAPI-managed fallback for generation -# input types that /v1/files does not accept, such as reference video. +# input types that /v1/files does not accept. BEATAPI_MANAGED_R2_REGION=auto BEATAPI_MANAGED_R2_ENDPOINT= BEATAPI_MANAGED_R2_ACCESS_KEY_ID= diff --git a/PROVIDERS.md b/PROVIDERS.md index 1bec26e..7b0a1bb 100644 --- a/PROVIDERS.md +++ b/PROVIDERS.md @@ -18,6 +18,8 @@ The adapter uses: Model support is defined in `src/core/effects/effect-registry.ts`, not discovered dynamically. This keeps Canvas and Studio behavior deterministic. When adding a model, update the registry, request mapping, media capability rules, and tests together. +Kling 2.6 and Kling 3.0 Motion Control are exposed as BeatAPI models. Each run requires exactly one character image and one MP4/MOV motion video uploaded through the connected BeatAPI account. The Workspace never asks users for a KIE key; BeatAPI owns the upstream provider route, billing, polling, and output persistence. + An API with a different request or polling contract still needs its own adapter. This repository does not ship placeholder KIE, Vidu, Evolink, Gemini, Fal, Replicate, or payment-provider integrations. ## Storage diff --git a/README.md b/README.md index 6e50363..c43e7d4 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ No shared R2 secret is committed to this repository. “Preconfigured” means t ## Supported model catalog -The canonical catalog lives in `src/core/effects/effect-registry.ts`. The current workspace exposes four image models and five video models through one BeatAPI adapter. Update the registry and adapter together when BeatAPI's public contract changes. +The canonical catalog lives in `src/core/effects/effect-registry.ts`. The current workspace exposes four image models and seven video models through one BeatAPI adapter, including Kling 2.6 and Kling 3.0 Motion Control. Update the registry and adapter together when BeatAPI's public contract changes. ## Commands diff --git a/messages/en.json b/messages/en.json index 87b7b2b..7510668 100644 --- a/messages/en.json +++ b/messages/en.json @@ -124,7 +124,7 @@ "toolbar": { "addNode": "Add node", "generationNode": "Generation node", - "uploadNode": "Upload node", + "uploadNode": "Upload media", "upload": "Upload", "assets": "Assets", "batchScenes": "Batch scenes" @@ -133,7 +133,7 @@ "templates": "Templates", "historyAssets": "History assets", "addNodeTitle": "Add node", - "uploadNodeTitle": "Upload node", + "uploadNodeTitle": "Upload media", "recentAssetsTitle": "Recent assets", "recentImages": "Recent images", "recentVideos": "Recent videos", @@ -190,6 +190,7 @@ "promptPlaceholder": "Describe the image or video you want to generate. When you select a canvas node, it becomes the current reference or branch anchor." }, "actions": { + "uploadMedia": "Upload media", "uploadImage": "Upload image", "uploadVideo": "Upload video", "combine": "Combine cards", @@ -275,6 +276,8 @@ "addNodeSuccess": "Prompt node added to the canvas.", "uploadImageInvalid": "Images must be JPG, PNG, or WebP and smaller than {maxSizeMb}MB.", "uploadVideoInvalid": "Videos must be MP4, WebM, or MOV and smaller than {maxSizeMb}MB.", + "uploadMediaInvalid": "{fileName} is not a supported image or video.", + "openingMediaPicker": "Opening media picker...", "openingImagePicker": "Opening image picker...", "openingVideoPicker": "Opening video picker...", "uploading": "Uploading asset...", @@ -358,6 +361,8 @@ "variant": "Variant", "quality": "Quality", "language": "Language", + "characterOrientation": "Character orientation", + "backgroundSource": "Background source", "tokens": { "quality": "Quality", "fast": "Fast", @@ -371,7 +376,11 @@ "landscape": "Landscape", "portrait": "Portrait", "chinese": "Chinese", - "english": "English" + "english": "English", + "imageOrientation": "Follow image", + "videoOrientation": "Follow video", + "inputImage": "Image background", + "inputVideo": "Video background" }, "status": { "queued": "Queued", @@ -402,6 +411,8 @@ "videoGeneration": "Video generation", "image": "Image", "video": "Video", + "previewLoading": "Loading preview...", + "previewUnavailable": "Preview unavailable", "processing": "Processing...", "pending": "Pending...", "generationFailed": "Generation failed", @@ -511,6 +522,7 @@ "workspaceNameLabel": "Workspace name", "projects": "Projects", "projectsLabel": "Open projects", + "githubRepository": "Open BeatAPI Workspace on GitHub", "projectAssets": { "triggerLabel": "Project assets", "title": "Project assets", diff --git a/messages/zh.json b/messages/zh.json index cf2d508..5ac6eff 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -125,7 +125,7 @@ "toolbar": { "addNode": "新增节点", "generationNode": "生成节点", - "uploadNode": "上传节点", + "uploadNode": "上传素材", "upload": "上传", "assets": "资产库", "batchScenes": "批量场景" @@ -134,7 +134,7 @@ "templates": "模板库", "historyAssets": "历史素材", "addNodeTitle": "新增节点", - "uploadNodeTitle": "上传节点", + "uploadNodeTitle": "上传素材", "recentAssetsTitle": "最近素材", "recentImages": "最近图片", "recentVideos": "最近视频", @@ -191,6 +191,7 @@ "promptPlaceholder": "描述你想生成的图片或视频。选中画布节点后,它会成为当前参考素材或分支锚点。" }, "actions": { + "uploadMedia": "上传素材", "uploadImage": "上传图片", "uploadVideo": "上传视频", "combine": "合并卡片", @@ -276,6 +277,8 @@ "addNodeSuccess": "提示节点已加入画布。", "uploadImageInvalid": "图片需为 JPG、PNG 或 WebP,且小于 {maxSizeMb}MB。", "uploadVideoInvalid": "视频需为 MP4、WebM 或 MOV,且小于 {maxSizeMb}MB。", + "uploadMediaInvalid": "{fileName} 不是支持的图片或视频格式。", + "openingMediaPicker": "正在打开素材选择器...", "openingImagePicker": "正在打开图片选择器...", "openingVideoPicker": "正在打开视频选择器...", "uploading": "上传素材中...", @@ -359,6 +362,8 @@ "variant": "版本", "quality": "质量", "language": "语种", + "characterOrientation": "角色朝向", + "backgroundSource": "背景来源", "tokens": { "quality": "质量", "fast": "快速", @@ -372,7 +377,11 @@ "landscape": "横向", "portrait": "纵向", "chinese": "中文", - "english": "英文" + "english": "英文", + "imageOrientation": "跟随图片", + "videoOrientation": "跟随视频", + "inputImage": "图片背景", + "inputVideo": "视频背景" }, "status": { "queued": "排队中", @@ -403,6 +412,8 @@ "videoGeneration": "视频生成", "image": "图片", "video": "视频", + "previewLoading": "正在加载预览...", + "previewUnavailable": "暂时无法预览", "processing": "处理中...", "pending": "等待中...", "generationFailed": "生成失败", @@ -512,6 +523,7 @@ "workspaceNameLabel": "空间名称", "projects": "项目", "projectsLabel": "打开项目列表", + "githubRepository": "在 GitHub 打开 BeatAPI Workspace", "projectAssets": { "triggerLabel": "项目素材", "title": "项目素材", diff --git a/src/components/app/product-page-shell.test.ts b/src/components/app/product-page-shell.test.ts index a0bd751..bc68aa7 100644 --- a/src/components/app/product-page-shell.test.ts +++ b/src/components/app/product-page-shell.test.ts @@ -19,6 +19,13 @@ 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, /GitHubIcon/); + assert.match( + shellSource, + /https:\/\/github\.com\/BeatAPI\/beatapi-workspace/ + ); + assert.match(shellSource, /target="_blank"/); + assert.match(shellSource, /rel="noreferrer"/); assert.match(shellSource, />Studio<\/span>/); assert.match(shellSource, />Canvas<\/span>/); assert.match(shellSource, /\{ workspaceMode \}/); diff --git a/src/components/app/product-page-shell.tsx b/src/components/app/product-page-shell.tsx index 40919ae..b635a6d 100644 --- a/src/components/app/product-page-shell.tsx +++ b/src/components/app/product-page-shell.tsx @@ -6,6 +6,7 @@ import { 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'; import { apiJsonPatch, apiJsonPost } from '@/lib/api-client'; @@ -159,6 +160,16 @@ export function ProductPageShell({ + + diff --git a/src/components/beatcanvas/beatcanvas-composer-parameter-picker.tsx b/src/components/beatcanvas/beatcanvas-composer-parameter-picker.tsx index 2ecbd38..abd2015 100644 --- a/src/components/beatcanvas/beatcanvas-composer-parameter-picker.tsx +++ b/src/components/beatcanvas/beatcanvas-composer-parameter-picker.tsx @@ -2,6 +2,8 @@ import type { WorkspaceAspectRatio, + WorkspaceBackgroundSource, + WorkspaceCharacterOrientation, WorkspaceDuration, WorkspaceLanguage, WorkspaceModelMode, @@ -33,6 +35,8 @@ export function BeatCanvasComposerParameterPicker({ isOpen, labels, onDraftAspectRatioChange, + onDraftBackgroundSourceChange, + onDraftCharacterOrientationChange, onDraftDurationChange, onDraftLanguageChange, onDraftModeChange, @@ -42,6 +46,8 @@ export function BeatCanvasComposerParameterPicker({ onOpenChange, parameterSummaryLabel, selectedAspectRatioOptions, + selectedBackgroundSourceOptions, + selectedCharacterOrientationOptions, selectedDurationOptions, selectedLanguageOptions, selectedModeOptions, @@ -60,6 +66,14 @@ export function BeatCanvasComposerParameterPicker({ draftId: string, aspectRatio: WorkspaceAspectRatio ) => void; + onDraftBackgroundSourceChange: ( + draftId: string, + backgroundSource: WorkspaceBackgroundSource + ) => void; + onDraftCharacterOrientationChange: ( + draftId: string, + characterOrientation: WorkspaceCharacterOrientation + ) => void; onDraftDurationChange: (draftId: string, duration: WorkspaceDuration) => void; onDraftLanguageChange: (draftId: string, language: WorkspaceLanguage) => void; onDraftModeChange: (draftId: string, mode: WorkspaceModelMode) => void; @@ -78,6 +92,8 @@ export function BeatCanvasComposerParameterPicker({ onOpenChange: (nextOpen: boolean) => void; parameterSummaryLabel: string; selectedAspectRatioOptions: WorkspaceAspectRatio[]; + selectedBackgroundSourceOptions: WorkspaceBackgroundSource[]; + selectedCharacterOrientationOptions: WorkspaceCharacterOrientation[]; selectedDurationOptions: WorkspaceDuration[]; selectedLanguageOptions: WorkspaceLanguage[]; selectedModeOptions: WorkspaceModelMode[]; @@ -236,6 +252,59 @@ export function BeatCanvasComposerParameterPicker({ ) : null} + {selectedCharacterOrientationOptions.length > 0 ? ( +
+

+ {labels.characterOrientationLabel} +

+
+ {selectedCharacterOrientationOptions.map((item) => ( + + ))} +
+
+ ) : null} + + {selectedBackgroundSourceOptions.length > 0 ? ( +
+

+ {labels.backgroundSourceLabel} +

+
+ {selectedBackgroundSourceOptions.map((item) => ( + + ))} +
+
+ ) : null} + {selectedLanguageOptions.length > 0 ? (

diff --git a/src/components/beatcanvas/beatcanvas-composer-reference-picker.test.tsx b/src/components/beatcanvas/beatcanvas-composer-reference-picker.test.tsx index a08e54b..b0264db 100644 --- a/src/components/beatcanvas/beatcanvas-composer-reference-picker.test.tsx +++ b/src/components/beatcanvas/beatcanvas-composer-reference-picker.test.tsx @@ -77,3 +77,84 @@ test('open picker lists attached and canvas reference cards', () => { assert.match(html, /Beach/); assert.match(html, /aria-label="Remove reference"/); }); + +test('open picker renders video references with video elements instead of images', () => { + const html = renderToStaticMarkup( + {}} + onOpenChange={() => {}} + onOpenReferencePicker={() => {}} + onRemoveCanvasReference={() => {}} + options={[{ intent: 'video', remaining: 2 }]} + primaryIntent="video" + /> + ); + + assert.equal((html.match(/

- + ))} +
) : null} - - {isOpen ? ( -
{ + onOpenChange(!isOpen); + }} + onPointerDownCapture={stopComposerEvent} className={cn( - composerFloatingPanelClassName, - 'left-0 w-[min(300px,calc(100vw-48px))]' + 'relative inline-flex shrink-0 items-center justify-center text-[var(--beat-text-3)] transition-all duration-200 hover:bg-white/[0.06] hover:text-white disabled:cursor-not-allowed disabled:opacity-60', + isRow + ? 'h-7 w-7 rounded-lg border border-dashed border-white/[0.12] bg-white/[0.025]' + : 'h-8 w-8 rounded-lg' )} - onPointerDownCapture={stopComposerEvent} + aria-label={primaryLabel} + aria-expanded={isOpen} > -
+ {primaryIntent === 'video' ? ( +
- ) : null} + ) : null} +
); } diff --git a/src/components/beatcanvas/beatcanvas-composer-shell.test.tsx b/src/components/beatcanvas/beatcanvas-composer-shell.test.tsx index a08281b..579d446 100644 --- a/src/components/beatcanvas/beatcanvas-composer-shell.test.tsx +++ b/src/components/beatcanvas/beatcanvas-composer-shell.test.tsx @@ -75,6 +75,8 @@ const labels = { aspectRatioLabel: 'Aspect ratio', outputQualityLabel: 'Output quality', durationLabel: 'Duration', + characterOrientationLabel: 'Character orientation', + backgroundSourceLabel: 'Background source', languageLabel: 'Language', uploadImageLabel: 'Upload image', uploadVideoLabel: 'Upload video', @@ -99,6 +101,10 @@ const labels = { tokenPortraitLabel: 'Portrait', tokenChineseLabel: 'Chinese', tokenEnglishLabel: 'English', + tokenImageOrientationLabel: 'Follow image', + tokenVideoOrientationLabel: 'Follow video', + tokenInputImageLabel: 'Image background', + tokenInputVideoLabel: 'Video background', queuedStatusLabel: 'Queued', generatingStatusLabel: 'Generating', readyStatusLabel: 'Ready', diff --git a/src/components/beatcanvas/beatcanvas-composer-shell.tsx b/src/components/beatcanvas/beatcanvas-composer-shell.tsx index ce5ab58..7ffd421 100644 --- a/src/components/beatcanvas/beatcanvas-composer-shell.tsx +++ b/src/components/beatcanvas/beatcanvas-composer-shell.tsx @@ -3,8 +3,15 @@ import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import type { CanvasGenerationCard } from '@/core/beatcanvas/canvas-types'; -import { ArrowUp, Loader2, RotateCw, X } from 'lucide-react'; -import type { ReactNode, RefObject } from 'react'; +import { + filterCanvasReferenceMentions, + findActiveCanvasReferenceMention, + insertCanvasReferenceMention, + type ActiveCanvasReferenceMention, + type CanvasReferenceMention, +} from '@/core/beatcanvas/reference-mentions'; +import { ArrowUp, Image, Loader2, RotateCw, Video, X } from 'lucide-react'; +import { useRef, useState, type ReactNode, type RefObject } from 'react'; import { stopComposerEvent, @@ -37,6 +44,7 @@ export function BeatCanvasComposerShell({ promptPlaceholder, presentation, promptAccessory, + promptReferences = [], position, takeCount = 0, }: { @@ -56,10 +64,19 @@ export function BeatCanvasComposerShell({ promptInputValue: string; promptPlaceholder: string; presentation?: BeatCanvasComposerPresentation | null; - promptAccessory?: ReactNode; + promptAccessory?: + | ReactNode + | ((insertReferenceMention: (alias: string) => void) => ReactNode); + promptReferences?: Array< + CanvasReferenceMention & { thumbnailUrl: string | null } + >; position?: { left: number; top: number } | null; takeCount?: number; }) { + const promptInputRef = useRef(null); + const [activeMention, setActiveMention] = + useState(null); + const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); const hasExistingTakes = takeCount > 0; const primaryButtonLabel = isDraftBusy ? labels.generatingLabel @@ -67,6 +84,41 @@ export function BeatCanvasComposerShell({ ? labels.regenerateLabel : labels.generateLabel; const isPrimaryDisabled = isDraftBusy || isPromptComposing; + const mentionOptions = activeMention + ? filterCanvasReferenceMentions({ + mentions: promptReferences, + query: activeMention.query, + }) + : []; + + const selectReferenceMention = ( + alias: string, + mentionOverride?: ActiveCanvasReferenceMention | null + ) => { + const input = promptInputRef.current; + const selectionStart = input?.selectionStart ?? promptInputValue.length; + const selectionEnd = input?.selectionEnd ?? selectionStart; + const result = insertCanvasReferenceMention({ + prompt: promptInputValue, + alias, + selectionStart, + selectionEnd, + activeMention: mentionOverride ?? activeMention, + }); + + onPromptChange(result.prompt); + setActiveMention(null); + setSelectedMentionIndex(0); + window.requestAnimationFrame(() => { + promptInputRef.current?.focus(); + promptInputRef.current?.setSelectionRange(result.caret, result.caret); + }); + }; + + const renderedPromptAccessory = + typeof promptAccessory === 'function' + ? promptAccessory((alias) => selectReferenceMention(alias, null)) + : promptAccessory; return (
- {promptAccessory ? ( + {renderedPromptAccessory ? (
- {promptAccessory} + {renderedPromptAccessory}
) : null} -