feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View File

@@ -17,6 +17,7 @@ import { Link, useRouter } from '@/i18n/navigation'
import { apiFetch } from '@/lib/api-fetch'
import { expandHomeStory } from '@/lib/home/ai-story-expand'
import { createHomeProjectLaunch } from '@/lib/home/create-project-launch'
import { formatDefaultProjectTimestamp } from '@/lib/projects/default-name'
import { HOME_QUICK_START_MIN_ROWS } from '@/lib/ui/textarea-height'
import AiWriteModal from '@/components/home/AiWriteModal'
@@ -52,6 +53,7 @@ export default function HomePage() {
const [artStyle, setArtStyle] = useState('american-comic')
const [stylePresetValue, setStylePresetValue] = useState<string>(DEFAULT_STYLE_PRESET_VALUE)
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [aiWriteOpen, setAiWriteOpen] = useState(false)
const [aiWriteLoading, setAiWriteLoading] = useState(false)
@@ -92,12 +94,15 @@ export default function HomePage() {
// 创建项目并跳转
const handleCreate = async () => {
if (!inputValue.trim() || createLoading) return
setCreateError(null)
setCreateLoading(true)
try {
const storyText = inputValue.trim()
const result = await createHomeProjectLaunch({
apiFetch,
projectName: storyText.slice(0, 50),
projectName: t('defaultProjectName', {
timestamp: formatDefaultProjectTimestamp(new Date()),
}),
storyText,
videoRatio,
artStyle,
@@ -107,7 +112,7 @@ export default function HomePage() {
router.push(result.target)
} catch (error) {
const message = error instanceof Error ? error.message : t('createFailed')
window.alert(message)
setCreateError(message)
} finally {
setCreateLoading(false)
}
@@ -245,9 +250,15 @@ export default function HomePage() {
<StoryInputComposer
value={inputValue}
onValueChange={setInputValue}
onValueChange={(nextValue) => {
setInputValue(nextValue)
if (createError) {
setCreateError(null)
}
}}
placeholder={t('inputPlaceholder')}
minRows={HOME_QUICK_START_MIN_ROWS}
textareaClassName="px-0 pt-0 pb-3 align-top"
videoRatio={videoRatio}
onVideoRatioChange={setVideoRatio}
ratioOptions={ratioOptions}
@@ -286,6 +297,11 @@ export default function HomePage() {
</span>
</button>
)}
footer={createError ? (
<p className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-600">
{createError}
</p>
) : null}
/>
</div>
</div>

View File

@@ -42,13 +42,11 @@ function NovelPromotionWorkspaceContent(props: NovelPromotionWorkspaceProps) {
const showStoryToScriptMinBadge =
storyToScriptStream.isVisible &&
storyToScriptStream.stages.length > 0 &&
storyToScriptActive &&
vm.execution.storyToScriptConsoleMinimized
const showScriptToStoryboardMinBadge =
scriptToStoryboardStream.isVisible &&
scriptToStoryboardStream.stages.length > 0 &&
scriptToStoryboardActive &&
vm.execution.scriptToStoryboardConsoleMinimized

View File

@@ -9,6 +9,7 @@ import { useTranslations } from 'next-intl'
import { useState, useRef, useEffect, useCallback } from 'react'
import '@/styles/animations.css'
import AiWriteModal from '@/components/home/AiWriteModal'
import LongTextDetectionPrompt from '@/components/story-input/LongTextDetectionPrompt'
import StoryInputComposer from '@/components/story-input/StoryInputComposer'
import { ART_STYLES, VIDEO_RATIOS } from '@/lib/constants'
import TaskStatusInline from '@/components/task/TaskStatusInline'
@@ -197,7 +198,7 @@ export default function NovelInputStage({
stylePresetValue={stylePresetValue}
onStylePresetChange={setStylePresetValue}
stylePresetOptions={STYLE_PRESETS}
textareaClassName="text-base p-5 pb-3"
textareaClassName="px-0 pt-0 pb-3 align-top"
primaryAction={(
<button
onClick={handleStartClick}
@@ -285,88 +286,29 @@ export default function NovelInputStage({
</div>
)}
{/* 长文本检测 — 智能分集强引导弹窗 */}
{showLongTextPrompt && (
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 backdrop-blur-sm">
<div className="w-full max-w-lg mx-4 relative">
{/* 渐变描边外壳 */}
<div
className="rounded-2xl p-[1.5px]"
style={{ background: 'linear-gradient(135deg, #3b82f6, #8b5cf6, #06b6d4)' }}
>
<div className="glass-surface-modal rounded-2xl p-6 space-y-5">
{/* 标题行 */}
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
>
<AppIcon name="sparkles" className="w-5 h-5 text-[#7c3aed]" />
</div>
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
{t('storyInput.longTextDetection.title')}
</h3>
</div>
{/* 描述 */}
<p className="text-sm text-[var(--glass-text-secondary)] leading-relaxed">
{t('storyInput.longTextDetection.description', { count: localText.trim().length.toLocaleString() })}
</p>
{/* 强烈推荐文案 */}
<div
className="p-4 rounded-xl text-sm leading-relaxed"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08))' }}
>
<p
className="font-semibold"
style={{
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{t('storyInput.longTextDetection.strongRecommend')}
</p>
</div>
{/* 按钮区域 */}
<div className="flex flex-col gap-3 pt-1">
{/* 智能分集 — 主按钮 */}
<button
onClick={() => {
setShowLongTextPrompt(false)
onSmartSplit?.(localText)
}}
className="w-full py-3.5 rounded-xl text-white font-semibold text-base flex items-center justify-center gap-2 transition-all hover:opacity-90 active:scale-[0.98]"
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
>
<AppIcon name="sparkles" className="w-5 h-5" />
<span>{t('storyInput.longTextDetection.smartSplit')}</span>
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
{t('storyInput.longTextDetection.smartSplitRecommend')}
</span>
</button>
{/* 直接创作 — 弱化按钮 */}
<button
onClick={() => {
setShowLongTextPrompt(false)
onNext()
}}
className="w-full py-2.5 text-sm text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)] transition-colors"
>
{t('storyInput.longTextDetection.continueAnyway')}
<span className="text-xs ml-1 opacity-60">
{t('storyInput.longTextDetection.singleEpisodeWarning')}
</span>
</button>
</div>
</div>
</div>
</div>
</div>
)}
<LongTextDetectionPrompt
open={showLongTextPrompt}
copy={{
title: t('storyInput.longTextDetection.title'),
description: t('storyInput.longTextDetection.description', {
count: localText.trim().length.toLocaleString(),
}),
strongRecommend: t('storyInput.longTextDetection.strongRecommend'),
smartSplitLabel: t('storyInput.longTextDetection.smartSplit'),
smartSplitBadge: t('storyInput.longTextDetection.smartSplitRecommend'),
continueLabel: t('storyInput.longTextDetection.continueAnyway'),
continueHint: t('storyInput.longTextDetection.singleEpisodeWarning'),
}}
onClose={() => setShowLongTextPrompt(false)}
onSmartSplit={() => {
setShowLongTextPrompt(false)
onSmartSplit?.(localText)
}}
onContinue={() => {
setShowLongTextPrompt(false)
onNext()
}}
/>
</div>
)
}

View File

@@ -21,7 +21,7 @@ export interface PanelEditData {
cameraMove: string | null
description: string | null
location: string | null
characters: { name: string; appearance: string }[]
characters: { name: string; appearance: string; slot?: string }[]
srtStart: number | null
srtEnd: number | null
duration: number | null
@@ -75,7 +75,7 @@ export default function PanelEditForm({
interface CharacterPickerModalProps {
projectId: string
currentCharacters: { name: string; appearance: string }[]
currentCharacters: { name: string; appearance: string; slot?: string }[]
onSelect: (charName: string, appearance: string) => void
onClose: () => void
}

View File

@@ -5,6 +5,7 @@ import { SettingsModal, WorldContextModal } from '@/components/ui/ConfigModals'
import WorkspaceTopActions from './WorkspaceTopActions'
import type { NovelPromotionPanel } from '@/types/project'
import type { CapabilitySelections, ModelCapabilities } from '@/lib/model-config-contract'
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
interface EpisodeSummary {
id: string
@@ -164,15 +165,23 @@ export default function WorkspaceHeaderShell({
return (
<EpisodeSelector
projectName={projectName}
episodes={sorted.map((ep) => ({
id: ep.id,
title: ep.name,
summary: ep.description ?? undefined,
status: {
script: ep.clips?.length ? 'ready' as const : 'empty' as const,
visual: ep.storyboards?.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' as const : 'empty' as const,
},
}))}
episodes={sorted.map((ep) => {
const stageArtifacts = resolveEpisodeStageArtifacts({
novelText: null,
clips: ep.clips || [],
storyboards: ep.storyboards || [],
voiceLines: [],
})
return {
id: ep.id,
title: ep.name,
summary: ep.description ?? undefined,
status: {
script: stageArtifacts.hasScript ? 'ready' as const : 'empty' as const,
visual: stageArtifacts.hasVideo ? 'ready' as const : 'empty' as const,
},
}
})}
currentId={currentEpisodeId}
onSelect={(id) => onEpisodeSelect?.(id)}
onAdd={onEpisodeCreate}

View File

@@ -65,7 +65,7 @@ export default function WorkspaceRunStreamConsoles({
const showStoryToScriptConsole =
storyToScriptStream.isVisible &&
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage)
(storyToScriptStream.stages.length > 0 || !!storyToScriptStream.errorMessage || storyToScriptActive)
const storyFallbackStatus: LLMStageViewItem['status'] =
storyToScriptStream.status === 'failed' ? 'failed' : 'processing'
const storyToScriptStages = storyToScriptStream.stages.length > 0
@@ -94,7 +94,7 @@ export default function WorkspaceRunStreamConsoles({
storyToScriptSelectedStage?.status === 'processing'
const showScriptToStoryboardConsole =
scriptToStoryboardStream.isVisible &&
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage)
(scriptToStoryboardStream.stages.length > 0 || !!scriptToStoryboardStream.errorMessage || scriptToStoryboardActive)
const storyboardFallbackStatus: LLMStageViewItem['status'] =
scriptToStoryboardStream.status === 'failed' ? 'failed' : 'processing'
const scriptToStoryboardStages = scriptToStoryboardStream.stages.length > 0

View File

@@ -10,6 +10,7 @@ import { useAiCreateProjectLocation, useCreateProjectLocation } from '@/lib/quer
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
interface AddLocationModalProps {
projectId: string
@@ -58,6 +59,7 @@ export default function AddLocationModal({
const [description, setDescription] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isAiDesigning, setIsAiDesigning] = useState(false)
const aiDesigningState = isAiDesigning
@@ -87,6 +89,7 @@ export default function AddLocationModal({
userInstruction: aiInstruction,
})
setDescription(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error: unknown) {
if (getErrorStatus(error) === 402) {
@@ -113,6 +116,7 @@ export default function AddLocationModal({
description: description.trim(),
artStyle,
count: locationGenerationCount,
availableSlots,
})
onSuccess()
onClose()

View File

@@ -140,6 +140,7 @@ export default function CharacterCard({
const imageUrlsWithIndex = rawImageUrls
.map((url, idx) => ({ url, originalIndex: idx }))
.filter((item) => !!item.url) as { url: string; originalIndex: number }[]
const generatedImageCount = imageUrlsWithIndex.length
const hasMultipleImages = imageUrlsWithIndex.length > 1
const selectedIndex = appearance.selectedIndex ?? null
@@ -218,22 +219,24 @@ export default function CharacterCard({
<>
<ImageGenerationInlineCountButton
prefix={isGroupTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('character')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
onClick={() => onRegenerate(generatedImageCount)}
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={t('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{onUndo && (appearance.previousImageUrl || appearance.previousImageUrls.length > 0) && (
<button

View File

@@ -170,7 +170,7 @@ export default function LocationCard({
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
hasRunningTask: isTaskRunning,
requestedCount: generationCount,
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
})
const displaySlotCount = displaySelectionImages.length
const hasMultipleImages = generatedImageCount > 1
@@ -192,22 +192,24 @@ export default function LocationCard({
<>
<ImageGenerationInlineCountButton
prefix={isGroupTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
) : (
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
</>
)}
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
value={generationCount}
options={getImageGenerationCountOptions('location')}
onValueChange={setGenerationCount}
onClick={() => onRegenerate(generationCount)}
onClick={() => onRegenerate(generatedImageCount)}
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
ariaLabel={t('image.regenCountAriaLabel')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={t('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{onUndo && hasPreviousVersion && (
<button

View File

@@ -1,11 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import AIDataModalFormPane from './AIDataModalFormPane'
import AIDataModalPreviewPane from './AIDataModalPreviewPane'
import { createPortal } from 'react-dom'
import { AppIcon } from '@/components/ui/icons'
import GlassButton from '@/components/ui/primitives/GlassButton'
import type { AIDataModalProps } from './AIDataModal.types'
import { useAIDataModalState } from './hooks/useAIDataModalState'
import { AppIcon } from '@/components/ui/icons'
import AIDataModalFormPane from './AIDataModalFormPane'
import AIDataModalPreviewPane from './AIDataModalPreviewPane'
import { lockModalPageScroll } from './modal-scroll-lock'
export type {
AIDataModalProps,
@@ -13,6 +17,7 @@ export type {
PhotographyRules,
ActingCharacter,
ActingNotes,
AIDataCharacter,
} from './AIDataModal.types'
export default function AIDataModal({
@@ -32,6 +37,7 @@ export default function AIDataModal({
onSave,
}: AIDataModalProps) {
const t = useTranslations('storyboard')
const [activeCharIdx, setActiveCharIdx] = useState(0)
const {
shotType,
@@ -78,29 +84,49 @@ export default function AIDataModal({
...(actingNotes.length > 0 ? { acting_notes: actingNotes } : {}),
}
if (!isOpen) return null
useEffect(() => {
if (!isOpen || typeof document === 'undefined') return undefined
return lockModalPageScroll(document)
}, [isOpen])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-[var(--glass-overlay)] backdrop-blur-sm" onClick={onClose} />
if (!isOpen || typeof document === 'undefined') return null
<div className="relative bg-[var(--glass-bg-surface)] rounded-2xl shadow-2xl w-[90vw] max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]">
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div className="glass-overlay absolute inset-0" onClick={onClose} />
<div
className="relative z-10 glass-surface-modal w-full max-w-[920px] flex flex-col overflow-hidden"
style={{ maxHeight: '92vh' }}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--glass-stroke-base)] flex-shrink-0">
<div className="flex items-center gap-3">
<span className="text-2xl" />
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--glass-radius-xs)] bg-[var(--glass-tone-info-bg)] flex-shrink-0">
<AppIcon name="clapperboard" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--glass-text-primary)]">{t('aiData.title')}</h2>
<p className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.subtitle', { number: panelNumber })}</p>
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] leading-none">
{t('aiData.title')}
</h2>
<p className="text-[11px] text-[var(--glass-text-tertiary)] mt-0.5">
{t('aiData.subtitle', { number: panelNumber })} · {videoRatio}
</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors">
<AppIcon name="close" className="w-5 h-5 text-[var(--glass-text-tertiary)]" />
<button
onClick={onClose}
className="glass-btn-base glass-btn-ghost h-7 w-7 flex-shrink-0"
aria-label={t('common.cancel')}
>
<AppIcon name="close" className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-hidden flex">
{/* Body */}
<div className="flex flex-1 min-h-0 overflow-hidden">
<AIDataModalFormPane
t={(key) => t(key as never)}
t={t}
shotType={shotType}
cameraMove={cameraMove}
description={description}
@@ -109,6 +135,8 @@ export default function AIDataModal({
videoPrompt={videoPrompt}
photographyRules={photographyRules}
actingNotes={actingNotes}
activeCharIdx={activeCharIdx}
onActiveCharIdxChange={setActiveCharIdx}
onShotTypeChange={setShotType}
onCameraMoveChange={setCameraMove}
onDescriptionChange={setDescription}
@@ -117,29 +145,34 @@ export default function AIDataModal({
onPhotographyCharacterChange={updatePhotographyCharacter}
onActingCharacterChange={updateActingCharacter}
/>
<AIDataModalPreviewPane
t={(key) => t(key as never)}
t={t}
previewJson={previewJson}
/>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)]">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--glass-text-secondary)] hover:text-[var(--glass-text-primary)] hover:bg-[var(--glass-bg-muted)] rounded-lg transition-colors"
>
{t('candidate.cancel')}
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm text-white bg-[var(--glass-accent-from)] hover:bg-[var(--glass-accent-to)] rounded-lg transition-colors flex items-center gap-2"
>
<AppIcon name="check" className="w-4 h-4" />
{t('aiData.save')}
</button>
{/* Footer */}
<div className="flex items-center justify-between border-t border-[var(--glass-stroke-base)] px-5 py-3 flex-shrink-0">
<p className="text-[11px] text-[var(--glass-text-tertiary)]">
{characters.map(c => c.name).join('、')}
{location ? ` · ${location}` : ''}
</p>
<div className="flex gap-2">
<GlassButton variant="secondary" size="sm" onClick={onClose}>
{t('common.cancel')}
</GlassButton>
<GlassButton
variant="primary"
size="sm"
onClick={handleSave}
iconLeft={<AppIcon name="check" className="h-3.5 w-3.5" />}
>
{t('aiData.save')}
</GlassButton>
</div>
</div>
</div>
</div>
</div>,
document.body
)
}

View File

@@ -1,5 +1,11 @@
'use client'
export interface AIDataCharacter {
name: string
appearance: string
slot?: string
}
export interface PhotographyCharacter {
name: string
screen_position: string
@@ -47,7 +53,7 @@ export interface AIDataModalProps {
cameraMove: string | null
description: string | null
location: string | null
characters: string[]
characters: AIDataCharacter[]
videoPrompt: string | null
photographyRules: PhotographyRules | null
actingNotes: ActingNotes | ActingCharacter[] | null

View File

@@ -1,21 +1,29 @@
'use client'
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
import type { useTranslations } from 'next-intl'
import GlassInput from '@/components/ui/primitives/GlassInput'
import GlassTextarea from '@/components/ui/primitives/GlassTextarea'
import { AppIcon } from '@/components/ui/icons'
import type {
ActingCharacter,
AIDataCharacter,
PhotographyCharacter,
PhotographyRules,
} from './AIDataModal.types'
interface AIDataModalFormPaneProps {
t: (key: string) => string
t: ReturnType<typeof useTranslations<'storyboard'>>
shotType: string
cameraMove: string
description: string
location: string | null
characters: string[]
characters: AIDataCharacter[]
videoPrompt: string
photographyRules: PhotographyRules | null
actingNotes: ActingCharacter[]
activeCharIdx: number
onActiveCharIdxChange: (idx: number) => void
onShotTypeChange: (value: string) => void
onCameraMoveChange: (value: string) => void
onDescriptionChange: (value: string) => void
@@ -25,6 +33,101 @@ interface AIDataModalFormPaneProps {
onActingCharacterChange: (index: number, field: keyof ActingCharacter, value: string) => void
}
function FL({ children }: { children: string }) {
return <p className="mb-1 text-[10.5px] font-semibold text-[var(--glass-text-tertiary)]">{children}</p>
}
function AutoGrowTextarea({
value,
onChange,
rows,
placeholder,
density = 'default',
className,
}: {
value: string
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void
rows: number
placeholder?: string
density?: 'compact' | 'default'
className?: string
}) {
const ref = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
}, [value])
return (
<GlassTextarea
ref={ref}
rows={rows}
value={value}
onChange={onChange}
onInput={(event) => {
const el = event.currentTarget
el.style.height = '0px'
el.style.height = `${el.scrollHeight}px`
}}
placeholder={placeholder}
density={density}
className={['overflow-hidden', className].filter(Boolean).join(' ')}
/>
)
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="flex items-center gap-2 mb-2.5">
<AppIcon name="sparkles" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[11px] font-semibold text-[var(--glass-text-primary)]">{children}</span>
</div>
)
}
function CollapseSection({
label,
iconName,
children,
}: {
label: string
iconName?: 'video' | 'film'
children: React.ReactNode
}) {
const [open, setOpen] = useState(false)
return (
<div className="border border-[var(--glass-stroke-base)] rounded-[var(--glass-radius-xs)] overflow-hidden">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between px-3.5 py-2.5 bg-[var(--glass-bg-muted)] hover:bg-[var(--glass-bg-surface)] transition-colors"
>
<div className="flex items-center gap-2">
{iconName ? (
<AppIcon
name={iconName}
className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0"
/>
) : null}
<span className="text-[11px] font-semibold text-[var(--glass-text-secondary)]">{label}</span>
</div>
<AppIcon
name={open ? 'chevronUp' : 'chevronDown'}
className="h-3.5 w-3.5 text-[var(--glass-text-tertiary)] flex-shrink-0"
/>
</button>
{open && (
<div className="px-3.5 py-3 space-y-3 bg-[var(--glass-bg-surface)]">
{children}
</div>
)}
</div>
)
}
export default function AIDataModalFormPane({
t,
shotType,
@@ -35,6 +138,8 @@ export default function AIDataModalFormPane({
videoPrompt,
photographyRules,
actingNotes,
activeCharIdx,
onActiveCharIdxChange,
onShotTypeChange,
onCameraMoveChange,
onDescriptionChange,
@@ -43,194 +148,257 @@ export default function AIDataModalFormPane({
onPhotographyCharacterChange,
onActingCharacterChange,
}: AIDataModalFormPaneProps) {
const activeChar = characters[activeCharIdx]
const photoChar = photographyRules?.characters.find(c => c.name === activeChar?.name)
const actingCharIdx = actingNotes.findIndex(n => n.name === activeChar?.name)
const actingChar = actingCharIdx >= 0 ? actingNotes[actingCharIdx] : null
return (
<div className="w-1/2 border-r border-[var(--glass-stroke-base)] overflow-y-auto p-6 space-y-5">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.basicData')}</div>
<div className="w-[55%] border-r border-[var(--glass-stroke-base)] overflow-y-auto p-5 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.shotType')}</label>
<input
type="text"
value={shotType}
onChange={(event) => onShotTypeChange(event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
placeholder={t('aiData.shotTypePlaceholder')}
/>
{/* ① 视觉描述 — 最高优先 */}
<section>
<div className="flex items-center gap-2 mb-2.5">
<AppIcon name="fileText" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[11px] font-semibold text-[var(--glass-text-primary)]">
{t('aiData.visualDescription')}
</span>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.cameraMove')}</label>
<input
type="text"
value={cameraMove}
onChange={(event) => onCameraMoveChange(event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
placeholder={t('aiData.cameraMovePlaceholder')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.scene')}</label>
<div className="px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]">
{location || t('aiData.notSelected')}
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.characters')}</label>
<div className="px-3 py-2 bg-[var(--glass-bg-muted)] border border-[var(--glass-stroke-base)] rounded-lg text-sm text-[var(--glass-text-secondary)]">
{characters.length > 0 ? characters.join('、') : t('common.none')}
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.visualDescription')}</label>
<textarea
value={description}
onChange={(event) => onDescriptionChange(event.target.value)}
<AutoGrowTextarea
rows={3}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
value={description}
onChange={e => onDescriptionChange(e.target.value)}
placeholder={t('insert.placeholder.description')}
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.videoPrompt')}</label>
<textarea
value={videoPrompt}
onChange={(event) => onVideoPromptChange(event.target.value)}
rows={2}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-warning-bg)]"
placeholder={t('panel.videoPromptPlaceholder')}
/>
</div>
{photographyRules && (
<>
<div className="border-t border-[var(--glass-stroke-base)] pt-4 mt-4">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.photographyRules')}</div>
</div>
</section>
{/* ② 镜头设置 */}
<section>
<SectionLabel>{t('aiData.shotAndScene')}</SectionLabel>
<div className="grid grid-cols-2 gap-3 mb-2">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.summary')}</label>
<input
type="text"
value={photographyRules.scene_summary || ''}
onChange={(event) => onPhotographyFieldChange('scene_summary', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingDirection')}</label>
<input
type="text"
value={photographyRules.lighting?.direction || ''}
onChange={(event) => onPhotographyFieldChange('lighting.direction', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.lightingQuality')}</label>
<input
type="text"
value={photographyRules.lighting?.quality || ''}
onChange={(event) => onPhotographyFieldChange('lighting.quality', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
<FL>{t('aiData.shotType')}</FL>
<div className="relative">
<AppIcon name="clapperboard" className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--glass-text-tertiary)]" />
<GlassInput
density="compact"
value={shotType}
onChange={e => onShotTypeChange(e.target.value)}
placeholder={t('aiData.shotTypePlaceholder')}
className="pl-9"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.depthOfField')}</label>
<input
type="text"
value={photographyRules.depth_of_field || ''}
onChange={(event) => onPhotographyFieldChange('depth_of_field', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
<FL>{t('aiData.cameraMove')}</FL>
<div className="relative">
<AppIcon name="video" className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--glass-text-tertiary)]" />
<GlassInput
density="compact"
value={cameraMove}
onChange={e => onCameraMoveChange(e.target.value)}
placeholder={t('aiData.cameraMovePlaceholder')}
className="pl-9"
/>
</div>
</div>
</div>
{/* 场景 + 比例 — 只读文字,不用 input 避免视觉干扰 */}
{location && (
<div className="flex items-center gap-2 text-[11.5px] text-[var(--glass-text-tertiary)]">
<AppIcon name="imageAlt" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span>
{t('aiData.scene').replace('(只读)', '')}<span className="text-[var(--glass-text-secondary)] font-medium">{location}</span>
</span>
</div>
)}
</section>
{/* ③ 角色详情 — tab 切换 */}
{characters.length > 0 && (
<section>
<SectionLabel>{t('aiData.characterDetails')}</SectionLabel>
{/* Tab 按钮 */}
<div className="flex gap-2 mb-3 flex-wrap">
{characters.map((char, i) => (
<button
key={i}
type="button"
onClick={() => onActiveCharIdxChange(i)}
className={[
'flex items-center gap-2 px-3 py-1.5 rounded-[var(--glass-radius-xs)] border text-xs font-semibold transition-all',
activeCharIdx === i
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'
: 'border-[var(--glass-stroke-base)] bg-[var(--glass-bg-muted)] text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]',
].join(' ')}
>
<div className={[
'h-5 w-5 rounded-full flex items-center justify-center flex-shrink-0',
activeCharIdx === i ? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]' : 'bg-[var(--glass-bg-surface)] text-[var(--glass-text-tertiary)]',
].join(' ')}>
<AppIcon name="user" className="h-3 w-3" />
</div>
{char.name}
{char.slot && (
<span className="glass-chip glass-chip-neutral text-[9.5px] inline-flex items-center gap-1">
<AppIcon name="badgeCheck" className="h-3 w-3" />
{char.slot}
</span>
)}
</button>
))}
</div>
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-1">{t('aiData.colorTone')}</label>
<input
type="text"
value={photographyRules.color_tone || ''}
onChange={(event) => onPhotographyFieldChange('color_tone', event.target.value)}
className="w-full px-3 py-2 border border-[var(--glass-stroke-strong)] rounded-lg text-sm focus:ring-2 focus:ring-[var(--glass-tone-info-fg)]"
/>
</div>
{/* 当前角色详情卡 */}
{activeChar && (
<div className="rounded-[var(--glass-radius-sm)] border border-[var(--glass-stroke-focus)] overflow-hidden">
{/* slot 行 */}
<div className="flex items-center gap-2 px-3.5 py-2 bg-[var(--glass-bg-muted)] border-b border-[var(--glass-stroke-base)] flex-wrap">
<AppIcon name="badgeCheck" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)] flex-shrink-0" />
<span className="text-[10.5px] font-semibold text-[var(--glass-text-tertiary)]">
{t('aiData.slot')}
</span>
<span className="glass-chip glass-chip-info text-[10.5px]">
{activeChar.slot ?? t('aiData.slotUnset')}
</span>
</div>
{photographyRules.characters && photographyRules.characters.length > 0 && (
<div>
<label className="block text-xs font-medium text-[var(--glass-text-secondary)] mb-2">{t('aiData.characterPosition')}</label>
<div className="space-y-3">
{photographyRules.characters.map((character, index) => (
<div key={index} className="p-3 bg-[var(--glass-bg-muted)] rounded-lg border border-[var(--glass-stroke-base)]">
<div className="text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2">{character.name}</div>
<div className="grid grid-cols-3 gap-2">
<div className="px-3.5 py-3 space-y-3 bg-[var(--glass-bg-surface)]">
{/* 外貌 — 只读 */}
{activeChar.appearance && (
<div>
<FL>{t('aiData.appearanceReadonly')}</FL>
<div className="flex items-start gap-2 rounded-[var(--glass-radius-xs)] bg-[var(--glass-bg-muted)] px-3 py-2">
<AppIcon name="sparkles" className="mt-0.5 h-3.5 w-3.5 text-[var(--glass-tone-warning-fg)] flex-shrink-0" />
<p className="text-[12px] text-[var(--glass-text-secondary)] leading-relaxed">
{activeChar.appearance}
</p>
</div>
</div>
)}
{/* 画面站位 */}
{photoChar && (
<div>
<FL>{t('aiData.framePosition')}</FL>
<div className="space-y-2">
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.position')}</label>
<input
type="text"
value={character.screen_position || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'screen_position', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.screenPosition')}</p>
<GlassInput
density="compact"
value={photoChar.screen_position}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'screen_position', e.target.value)
}}
/>
</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.posture')}</label>
<input
type="text"
value={character.posture || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'posture', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
/>
</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.facing')}</label>
<input
type="text"
value={character.facing || ''}
onChange={(event) => onPhotographyCharacterChange(index, 'facing', event.target.value)}
className="w-full px-2 py-1 border border-[var(--glass-stroke-base)] rounded text-xs"
/>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.posture')}</p>
<GlassInput
density="compact"
value={photoChar.posture}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'posture', e.target.value)
}}
/>
</div>
<div>
<p className="text-[10px] text-[var(--glass-text-tertiary)] mb-1">{t('aiData.facing')}</p>
<GlassInput
density="compact"
value={photoChar.facing}
onChange={e => {
const idx = photographyRules!.characters.findIndex(c => c.name === activeChar.name)
if (idx >= 0) onPhotographyCharacterChange(idx, 'facing', e.target.value)
}}
/>
</div>
</div>
</div>
</div>
))}
)}
{/* 表演指导 */}
{actingChar && (
<div>
<FL>{t('aiData.actingGuide')}</FL>
<AutoGrowTextarea
density="compact"
rows={2}
value={actingChar.acting}
onChange={e => onActingCharacterChange(actingCharIdx, 'acting', e.target.value)}
/>
</div>
)}
</div>
</div>
)}
</>
</section>
)}
{actingNotes.length > 0 && (
<>
<div className="border-t border-[var(--glass-stroke-base)] pt-4 mt-4">
<div className="text-sm font-medium text-[var(--glass-text-secondary)] mb-3">{t('aiData.actingNotes')}</div>
</div>
{/* ④ 视频提示词 — 折叠 */}
<CollapseSection label={t('aiData.videoPrompt')} iconName="video">
<AutoGrowTextarea
rows={4}
value={videoPrompt}
onChange={e => onVideoPromptChange(e.target.value)}
placeholder={t('panel.videoPromptPlaceholder')}
className="bg-[var(--glass-tone-warning-bg)]"
/>
</CollapseSection>
<div className="space-y-3">
{actingNotes.map((character, index) => (
<div key={index} className="p-3 bg-[var(--glass-tone-info-bg)] rounded-lg border border-[var(--glass-stroke-focus)]">
<div className="text-xs font-medium text-[var(--glass-tone-info-fg)] mb-2">{character.name}</div>
<div>
<label className="block text-[10px] text-[var(--glass-text-tertiary)] mb-0.5">{t('aiData.actingDescription')}</label>
<textarea
value={character.acting || ''}
onChange={(event) => onActingCharacterChange(index, 'acting', event.target.value)}
rows={2}
className="w-full px-2 py-1 border border-[var(--glass-stroke-focus)] rounded text-xs resize-none focus:ring-2 focus:ring-[var(--glass-tone-info-fg)] focus:border-[var(--glass-stroke-focus)]"
/>
</div>
</div>
))}
{/* ⑤ 摄影环境 — 折叠 */}
{photographyRules && (
<CollapseSection label={t('aiData.photoEnv')} iconName="film">
<div>
<FL>{t('aiData.summary')}</FL>
<GlassInput
density="compact"
value={photographyRules.scene_summary}
onChange={e => onPhotographyFieldChange('scene_summary', e.target.value)}
/>
</div>
</>
<div className="grid grid-cols-2 gap-3">
<div>
<FL>{t('aiData.lightingDirection')}</FL>
<GlassInput
density="compact"
value={photographyRules.lighting?.direction ?? ''}
onChange={e => onPhotographyFieldChange('lighting.direction', e.target.value)}
/>
</div>
<div>
<FL>{t('aiData.lightingQuality')}</FL>
<GlassInput
density="compact"
value={photographyRules.lighting?.quality ?? ''}
onChange={e => onPhotographyFieldChange('lighting.quality', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FL>{t('aiData.depthOfField')}</FL>
<GlassInput
density="compact"
value={photographyRules.depth_of_field}
onChange={e => onPhotographyFieldChange('depth_of_field', e.target.value)}
/>
</div>
<div>
<FL>{t('aiData.colorTone')}</FL>
<GlassInput
density="compact"
value={photographyRules.color_tone}
onChange={e => onPhotographyFieldChange('color_tone', e.target.value)}
/>
</div>
</div>
</CollapseSection>
)}
</div>
)

View File

@@ -1,46 +1,89 @@
'use client'
import { useState } from 'react'
import type { useTranslations } from 'next-intl'
import { AppIcon } from '@/components/ui/icons'
import GlassButton from '@/components/ui/primitives/GlassButton'
interface AIDataModalPreviewPaneProps {
t: (key: string) => string
t: ReturnType<typeof useTranslations<'storyboard'>>
previewJson: Record<string, unknown>
}
export async function copyPreviewJsonText(text: string): Promise<void> {
const clipboardApi = globalThis.navigator?.clipboard
if (clipboardApi && typeof clipboardApi.writeText === 'function') {
try {
await clipboardApi.writeText(text)
return
} catch {
// Fall through to manual copy fallback.
}
}
if (typeof document === 'undefined') {
throw new Error('Clipboard unavailable')
}
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
const copied = typeof document.execCommand === 'function' && document.execCommand('copy')
document.body.removeChild(el)
if (!copied) {
throw new Error('Clipboard fallback failed')
}
}
export default function AIDataModalPreviewPane({
t,
previewJson,
}: AIDataModalPreviewPaneProps) {
const [copyState, setCopyState] = useState<'idle' | 'success' | 'error'>('idle')
const handleCopy = async () => {
const text = JSON.stringify(previewJson, null, 2)
try {
await copyPreviewJsonText(text)
setCopyState('success')
} catch {
setCopyState('error')
}
window.setTimeout(() => setCopyState('idle'), 1600)
}
const copyLabel = t('common.copy')
const copyIconName = copyState === 'success' ? 'clipboardCheck' : copyState === 'error' ? 'alert' : 'copy'
return (
<div className="w-1/2 bg-[var(--glass-text-primary)] overflow-y-auto p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-[var(--glass-text-tertiary)]">{t('aiData.jsonPreview')}</span>
<button
onClick={() => {
const text = JSON.stringify(previewJson, null, 2)
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text).catch(() => { })
} else {
// HTTP 环境 fallback
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
}}
className="text-xs text-[var(--glass-tone-info-fg)] hover:text-[var(--glass-text-primary)] flex items-center gap-1"
<div className="w-[45%] flex flex-col overflow-hidden bg-[var(--glass-bg-muted)]">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] flex-shrink-0">
<div className="flex items-center gap-2">
<AppIcon name="fileText" className="h-3.5 w-3.5 text-[var(--glass-tone-info-fg)]" />
<span className="text-xs font-medium text-[var(--glass-text-tertiary)]">
{t('aiData.jsonCheck')}
</span>
</div>
<GlassButton
size="sm"
variant="ghost"
onClick={handleCopy}
iconLeft={<AppIcon name={copyIconName} className="h-3 w-3" />}
>
<AppIcon name="copy" className="w-3.5 h-3.5" />
{t('common.copy')}
</button>
{copyLabel}
</GlassButton>
</div>
<div className="flex-1 overflow-y-auto p-4">
<pre className="text-[11px] font-mono leading-relaxed text-[var(--glass-text-secondary)] whitespace-pre-wrap break-all">
{JSON.stringify(previewJson, null, 2)}
</pre>
</div>
<pre className="text-xs text-[var(--glass-tone-success-fg)] font-mono whitespace-pre-wrap break-all">
{JSON.stringify(previewJson, null, 2)}
</pre>
</div>
)
}

View File

@@ -64,13 +64,13 @@ export function useStoryboardAiDataRuntime({
const panelData = getPanelEditData(panel)
const photographyRules = parseJsonSafely(panel.photographyRules, 'photographyRules')
const actingNotes = parseJsonSafely(panel.actingNotes, 'actingNotes')
const characterNames = panelData.characters.map((character) => character.name)
const characters = panelData.characters.map((character) => ({ ...character }))
return {
panelData,
panel,
storyboardId: storyboard.id,
characterNames,
characters,
photographyRules,
actingNotes,
}

View File

@@ -20,7 +20,7 @@ export interface StoryboardPanel {
shot_type: string
camera_move: string | null
description: string
characters: { name: string; appearance: string }[]
characters: { name: string; appearance: string; slot?: string }[]
location?: string
srt_range?: string
duration?: number
@@ -112,12 +112,22 @@ export function useStoryboardState({
return sortedPanels.map((p) => {
const parsedChars = p.characters ? JSON.parse(p.characters) : []
const characters = Array.isArray(parsedChars)
? parsedChars.filter((item): item is { name: string; appearance: string } => (
typeof item === 'object'
&& item !== null
&& typeof (item as { name?: unknown }).name === 'string'
&& typeof (item as { appearance?: unknown }).appearance === 'string'
))
? parsedChars.flatMap((item): Array<{ name: string; appearance: string; slot?: string }> => {
if (
typeof item !== 'object'
|| item === null
|| typeof (item as { name?: unknown }).name !== 'string'
|| typeof (item as { appearance?: unknown }).appearance !== 'string'
) {
return []
}
const candidate = item as { name: string; appearance: string; slot?: unknown }
return [{
name: candidate.name,
appearance: candidate.appearance,
slot: typeof candidate.slot === 'string' ? candidate.slot : undefined,
}]
})
: []
return {
id: p.id,

View File

@@ -237,7 +237,7 @@ export default function StoryboardStage({
cameraMove={modalRuntime.aiDataRuntime.panelData.cameraMove}
description={modalRuntime.aiDataRuntime.panelData.description}
location={modalRuntime.aiDataRuntime.panelData.location}
characters={modalRuntime.aiDataRuntime.characterNames}
characters={modalRuntime.aiDataRuntime.characters}
videoPrompt={modalRuntime.aiDataRuntime.panelData.videoPrompt}
photographyRules={modalRuntime.aiDataRuntime.photographyRules}
actingNotes={modalRuntime.aiDataRuntime.actingNotes}

View File

@@ -0,0 +1,23 @@
interface ScrollLockTarget {
style: {
overflow: string
}
}
interface ScrollLockDocumentLike {
body: ScrollLockTarget
documentElement: ScrollLockTarget
}
export function lockModalPageScroll(doc: ScrollLockDocumentLike): () => void {
const previousBodyOverflow = doc.body.style.overflow
const previousHtmlOverflow = doc.documentElement.style.overflow
doc.body.style.overflow = 'hidden'
doc.documentElement.style.overflow = 'hidden'
return () => {
doc.body.style.overflow = previousBodyOverflow
doc.documentElement.style.overflow = previousHtmlOverflow
}
}

View File

@@ -20,6 +20,7 @@ import { useWorkspaceAutoRun } from './useWorkspaceAutoRun'
import { buildWorkspaceControllerViewModel } from './workspace-controller-view-model'
import type { NovelPromotionWorkspaceProps } from '../types'
import { useRouter } from '@/i18n/navigation'
import { resolveEpisodeStageArtifacts } from '@/lib/novel-promotion/stage-readiness'
export function useNovelPromotionWorkspaceController({
project,
@@ -38,7 +39,7 @@ export function useNovelPromotionWorkspaceController({
const { onRefresh } = useWorkspaceProvider()
const projectSnapshot = useWorkspaceProjectSnapshot({ project, episode, urlStage })
const { currentStage, episodeStoryboards, ...projectSection } = projectSnapshot
const { currentStage, ...projectSection } = projectSnapshot
const assetsLoading = false
const assetsLoadingState = assetsLoading
@@ -116,6 +117,11 @@ export function useNovelPromotionWorkspaceController({
execution.storyToScriptStream.isRunning ||
execution.storyToScriptStream.isRecoveredRunning ||
execution.storyToScriptStream.status === 'running'
const isScriptToStoryboardRunning =
execution.scriptToStoryboardStream.isRunning ||
execution.scriptToStoryboardStream.isRecoveredRunning ||
execution.scriptToStoryboardStream.status === 'running'
const stageArtifacts = resolveEpisodeStageArtifacts(episode)
const isAnyOperationRunning =
isStartingStoryToScript ||
@@ -124,8 +130,8 @@ export function useNovelPromotionWorkspaceController({
execution.isAssetAnalysisRunning ||
execution.isConfirmingAssets ||
execution.isTransitioning ||
execution.storyToScriptStream.isRunning ||
execution.scriptToStoryboardStream.isRunning
isStoryToScriptRunning ||
isScriptToStoryboardRunning
useWorkspaceAutoRun({
searchParams,
@@ -140,9 +146,7 @@ export function useNovelPromotionWorkspaceController({
const capsuleNavItems = useWorkspaceStageNavigation({
isAnyOperationRunning,
episode,
projectCharacterCount: projectSnapshot.projectCharacters.length,
episodeStoryboards,
stageArtifacts,
t,
})

View File

@@ -1,15 +1,6 @@
'use client'
import type { NovelPromotionPanel } from '@/types/project'
interface EpisodeLike {
novelText?: string | null
voiceLines?: unknown[] | null
}
interface StoryboardLike {
panels?: NovelPromotionPanel[] | null
}
import type { StageArtifactReadiness } from '@/lib/novel-promotion/stage-readiness'
interface CapsuleNavItem {
id: string
@@ -22,17 +13,13 @@ interface CapsuleNavItem {
interface UseWorkspaceStageNavigationParams {
isAnyOperationRunning: boolean
episode?: EpisodeLike | null
projectCharacterCount: number
episodeStoryboards: StoryboardLike[]
stageArtifacts: StageArtifactReadiness
t: (key: string) => string
}
export function useWorkspaceStageNavigation({
isAnyOperationRunning,
episode,
projectCharacterCount,
episodeStoryboards,
stageArtifacts,
t,
}: UseWorkspaceStageNavigationParams): CapsuleNavItem[] {
const getStageStatus = (stageId: string): 'empty' | 'active' | 'processing' | 'ready' => {
@@ -40,16 +27,16 @@ export function useWorkspaceStageNavigation({
switch (stageId) {
case 'config':
return episode?.novelText ? 'ready' : 'active'
return stageArtifacts.hasStory ? 'ready' : 'active'
case 'assets':
return projectCharacterCount > 0 ? 'ready' : 'empty'
return stageArtifacts.hasScript ? 'ready' : 'empty'
case 'storyboard':
return episodeStoryboards.some((sb) => sb.panels?.length) ? 'ready' : 'empty'
return stageArtifacts.hasStoryboard ? 'ready' : 'empty'
case 'videos':
case 'editor':
return episodeStoryboards.some((sb) => sb.panels?.some((panel) => panel.videoUrl)) ? 'ready' : 'empty'
return stageArtifacts.hasVideo ? 'ready' : 'empty'
case 'voice':
return (episode?.voiceLines?.length || 0) > 0 ? 'ready' : 'empty'
return stageArtifacts.hasVoice ? 'ready' : 'empty'
default:
return 'empty'
}

View File

@@ -9,6 +9,7 @@ import { useImageGenerationCount } from '@/lib/image-generation/use-image-genera
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
interface AddLocationModalProps {
folderId: string | null
@@ -33,6 +34,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
const [summary, setSummary] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const aiDesignMutation = useAiDesignLocation()
const createLocationMutation = useCreateAssetHubLocation()
@@ -63,6 +65,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
try {
const data = await aiDesignMutation.mutateAsync(aiInstruction.trim())
setSummary(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error) {
_ulogError('AI设计失败:', error)
@@ -80,6 +83,7 @@ export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationMo
folderId,
artStyle,
count: locationGenerationCount,
availableSlots,
})
onSuccess()
} catch (error) {

View File

@@ -91,7 +91,8 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
}
const imageUrls = appearance?.imageUrls || []
const hasMultipleImages = imageUrls.filter(u => isValidUrl(u)).length > 1
const generatedImageCount = imageUrls.filter(u => isValidUrl(u)).length
const hasMultipleImages = generatedImageCount > 1
const effectiveSelectedIndex: number | null = appearance?.selectedIndex ?? null
const currentImageUrl = appearance?.imageUrl || (effectiveSelectedIndex !== null ? imageUrls[effectiveSelectedIndex] : null) || imageUrls.find(u => u) || null
const hasPreviousVersion = !!(appearance?.previousImageUrl || (appearance?.previousImageUrls && appearance.previousImageUrls.length > 0))
@@ -250,22 +251,27 @@ export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDes
<div className="flex items-center gap-1">
<ImageGenerationInlineCountButton
prefix={isAppearanceTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
) : (
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
value={generationCount}
options={getImageGenerationCountOptions('character')}
onValueChange={setGenerationCount}
onClick={() => {
_ulogInfo('[CharacterCard] 多图模式 - 重新生成按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount)
handleGenerate(generationCount)
handleGenerate(generatedImageCount)
}}
disabled={isAppearanceTaskRunning}
ariaLabel={tAssets('image.selectCount')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={tAssets('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center gap-1 rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{hasPreviousVersion && (
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>

View File

@@ -94,7 +94,7 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
const isTaskRunning = serverTaskRunning || transientSubmitting
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
hasRunningTask: isTaskRunning,
requestedCount: generationCount,
requestedCount: generatedImageCount > 1 ? generatedImageCount : generationCount,
})
const displaySlotCount = displaySelectionImages.length
const hasMultipleImages = generatedImageCount > 1
@@ -226,19 +226,24 @@ export function LocationCard({ location, assetType = 'location', onImageClick, o
<div className="flex items-center gap-1 ml-2">
<ImageGenerationInlineCountButton
prefix={isTaskRunning ? (
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<>
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
) : (
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<>
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{tAssets('image.regenCountPrefix')}</span>
</>
)}
suffix={null}
value={generationCount}
options={getImageGenerationCountOptions('location')}
onValueChange={setGenerationCount}
onClick={() => handleGenerate(generationCount)}
onClick={() => handleGenerate(generatedImageCount)}
disabled={isTaskRunning}
ariaLabel={tAssets('image.selectCount')}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
showCountControl={false}
ariaLabel={tAssets('image.regenCountPrefix')}
className="inline-flex h-6 items-center justify-center gap-1 rounded-md px-1.5 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
/>
{hasPreviousVersion && (
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>

View File

@@ -11,6 +11,8 @@ import { AppIcon, IconGradientDefs } from '@/components/ui/icons'
import { shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
import { Link, useRouter } from '@/i18n/navigation'
import { apiFetch } from '@/lib/api-fetch'
import { readApiErrorMessage } from '@/lib/api/read-error-message'
import { validateProjectDraft } from '@/lib/projects/validation'
interface ProjectStats {
episodes: number
@@ -45,6 +47,24 @@ function formatProjectCost(amount: number, currency = DEFAULT_BILLING_CURRENCY):
return `¥${amount.toFixed(2)}`
}
function toProjectValidationMessage(
issue: ReturnType<typeof validateProjectDraft>,
t: ReturnType<typeof useTranslations>,
): string | null {
if (!issue) return null
switch (issue.code) {
case 'PROJECT_NAME_REQUIRED':
return t('validation.nameRequired')
case 'PROJECT_NAME_TOO_LONG':
return t('validation.nameTooLong')
case 'PROJECT_DESCRIPTION_TOO_LONG':
return t('validation.descriptionTooLong')
}
return null
}
export default function WorkspacePage() {
const { data: session, status } = useSession()
const router = useRouter()
@@ -52,12 +72,14 @@ export default function WorkspacePage() {
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [formData, setFormData] = useState({
name: '',
description: ''
})
const [editingProject, setEditingProject] = useState<Project | null>(null)
const [showEditModal, setShowEditModal] = useState(false)
const [editError, setEditError] = useState<string | null>(null)
const [editFormData, setEditFormData] = useState({
name: '',
description: ''
@@ -124,6 +146,7 @@ export default function WorkspacePage() {
// 打开新建项目弹窗并检测模型配置
const openCreateModal = useCallback(() => {
setCreateError(null)
setShowCreateModal(true)
// 异步检测模型配置状态
void (async () => {
@@ -146,8 +169,13 @@ export default function WorkspacePage() {
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name.trim()) return
const validationMessage = toProjectValidationMessage(validateProjectDraft(formData), t)
if (validationMessage) {
setCreateError(validationMessage)
return
}
setCreateError(null)
setCreateLoading(true)
try {
const response = await apiFetch('/api/projects', {
@@ -181,11 +209,11 @@ export default function WorkspacePage() {
router.push({ pathname: '/profile' })
}
} else {
alert(t('createFailed'))
setCreateError(await readApiErrorMessage(response, t('createFailed')))
}
} catch (error) {
_ulogError('创建项目失败:', error)
alert(t('createFailed'))
setCreateError(error instanceof Error ? error.message : t('createFailed'))
} finally {
setCreateLoading(false)
}
@@ -207,8 +235,15 @@ export default function WorkspacePage() {
const handleEditProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!editingProject || !editFormData.name.trim()) return
if (!editingProject) return
const validationMessage = toProjectValidationMessage(validateProjectDraft(editFormData), t)
if (validationMessage) {
setEditError(validationMessage)
return
}
setEditError(null)
setCreateLoading(true)
try {
const response = await apiFetch(`/api/projects/${editingProject.id}`, {
@@ -226,10 +261,10 @@ export default function WorkspacePage() {
setEditingProject(null)
setEditFormData({ name: '', description: '' })
} else {
alert(t('updateFailed'))
setEditError(await readApiErrorMessage(response, t('updateFailed')))
}
} catch {
alert(t('updateFailed'))
} catch (error) {
setEditError(error instanceof Error ? error.message : t('updateFailed'))
} finally {
setCreateLoading(false)
}
@@ -276,6 +311,7 @@ export default function WorkspacePage() {
e.preventDefault() // 阻止 Link 导航
e.stopPropagation()
setEditingProject(project)
setEditError(null)
setEditFormData({
name: project.name,
description: project.description || ''
@@ -574,7 +610,12 @@ export default function WorkspacePage() {
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value })
if (createError) {
setCreateError(null)
}
}}
className="glass-input-base w-full px-3 py-2"
placeholder={t('projectNamePlaceholder')}
maxLength={100}
@@ -589,18 +630,29 @@ export default function WorkspacePage() {
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value })
if (createError) {
setCreateError(null)
}
}}
className="glass-textarea-base w-full px-3 py-2"
placeholder={t('projectDescriptionPlaceholder')}
rows={3}
maxLength={500}
/>
</div>
{createError && (
<p className="mb-4 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600">
{createError}
</p>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowCreateModal(false)
setCreateError(null)
setFormData({ name: '', description: '' })
}}
className="glass-btn-base glass-btn-secondary px-4 py-2"
@@ -635,7 +687,12 @@ export default function WorkspacePage() {
id="edit-name"
type="text"
value={editFormData.name}
onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
onChange={(e) => {
setEditFormData({ ...editFormData, name: e.target.value })
if (editError) {
setEditError(null)
}
}}
className="glass-input-base w-full px-3 py-2"
placeholder={t('projectNamePlaceholder')}
maxLength={100}
@@ -649,19 +706,30 @@ export default function WorkspacePage() {
<textarea
id="edit-description"
value={editFormData.description}
onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}
onChange={(e) => {
setEditFormData({ ...editFormData, description: e.target.value })
if (editError) {
setEditError(null)
}
}}
className="glass-textarea-base w-full px-3 py-2"
placeholder={t('projectDescriptionPlaceholder')}
rows={3}
maxLength={500}
/>
</div>
{editError && (
<p className="mb-4 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600">
{editError}
</p>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => {
setShowEditModal(false)
setEditingProject(null)
setEditError(null)
setEditFormData({ name: '', description: '' })
}}
className="glass-btn-base glass-btn-secondary px-4 py-2"

View File

@@ -5,6 +5,10 @@ import { ApiError, apiHandler } from '@/lib/api-errors'
import { attachMediaFieldsToGlobalLocation } from '@/lib/media/attach'
import { isArtStyleValue } from '@/lib/constants'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
import {
normalizeLocationAvailableSlots,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
// 获取用户所有场景(支持 folderId 筛选)
export const GET = apiHandler(async (request: NextRequest) => {
@@ -45,6 +49,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const body = await request.json()
const { name, summary, folderId, artStyle } = body
const availableSlots = normalizeLocationAvailableSlots((body as Record<string, unknown>).availableSlots)
const count = Object.prototype.hasOwnProperty.call(body as Record<string, unknown>, 'count')
? normalizeImageGenerationCount('location', (body as Record<string, unknown>).count)
: 1
@@ -84,6 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
locationId: location.id,
imageIndex,
description: summary?.trim() || name.trim(),
availableSlots: stringifyLocationAvailableSlots(availableSlots),
}))
})

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { removeLocationPromptSuffix, isArtStyleValue, type ArtStyleValue } from '@/lib/constants'
import {
normalizeLocationAvailableSlots,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
import { requireProjectAuth, requireProjectAuthLight, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
@@ -56,6 +60,7 @@ export const POST = apiHandler(async (
const name = normalizeString(body.name)
const description = normalizeString(body.description)
const summary = normalizeString(body.summary)
const availableSlots = normalizeLocationAvailableSlots(body.availableSlots)
const count = Object.prototype.hasOwnProperty.call(body, 'count')
? normalizeImageGenerationCount('location', body.count)
: 1
@@ -91,6 +96,7 @@ export const POST = apiHandler(async (
locationId: location.id,
imageIndex,
description: cleanDescription,
availableSlots: stringifyLocationAvailableSlots(availableSlots),
})),
})
@@ -141,7 +147,12 @@ export const PATCH = apiHandler(async (
where: {
locationId_imageIndex: { locationId, imageIndex }
},
data: { description: cleanDescription }
data: {
description: cleanDescription,
...(Object.prototype.hasOwnProperty.call(body, 'availableSlots')
? { availableSlots: stringifyLocationAvailableSlots(normalizeLocationAvailableSlots(body.availableSlots)) }
: {}),
}
})
return NextResponse.json({ success: true, image })
}

View File

@@ -4,6 +4,25 @@ import { requireUserAuth, isErrorResponse } from '@/lib/api-auth'
import { apiHandler, ApiError } from '@/lib/api-errors'
import { toMoneyNumber } from '@/lib/billing/money'
import { isArtStyleValue } from '@/lib/constants'
import { resolveTaskLocale } from '@/lib/task/resolve-locale'
import {
formatProjectValidationIssue,
normalizeProjectDraft,
validateProjectDraft,
type ProjectDraftInput,
} from '@/lib/projects/validation'
function readProjectDraftBody(body: unknown): ProjectDraftInput {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { name: '' }
}
const payload = body as Record<string, unknown>
return {
name: typeof payload.name === 'string' ? payload.name : '',
description: typeof payload.description === 'string' ? payload.description : null,
}
}
// GET - 获取用户的项目(支持分页和搜索)
export const GET = apiHandler(async (request: NextRequest) => {
@@ -169,19 +188,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (isErrorResponse(authResult)) return authResult
const { session } = authResult
const { name, description } = await request.json()
if (!name || name.trim().length === 0) {
throw new ApiError('INVALID_PARAMS')
const body = await request.json()
const draft = readProjectDraftBody(body)
const validationIssue = validateProjectDraft(draft)
if (validationIssue) {
const locale = resolveTaskLocale(request, body) ?? 'zh'
throw new ApiError('INVALID_PARAMS', {
code: validationIssue.code,
field: validationIssue.field,
...(typeof validationIssue.limit === 'number' ? { limit: validationIssue.limit } : {}),
message: formatProjectValidationIssue(validationIssue, locale),
})
}
if (name.length > 100) {
throw new ApiError('INVALID_PARAMS')
}
if (description && description.length > 500) {
throw new ApiError('INVALID_PARAMS')
}
const { name, description } = normalizeProjectDraft(draft)
// 获取用户偏好配置
const userPreference = await prisma.userPreference.findUnique({

View File

@@ -35,6 +35,14 @@ function normalizeStatuses(values: string[]): RunStatus[] {
return next
}
function isActiveRunStatus(status: RunStatus) {
return (
status === RUN_STATUS.QUEUED
|| status === RUN_STATUS.RUNNING
|| status === RUN_STATUS.CANCELING
)
}
export const GET = apiHandler(async (request: NextRequest) => {
const authResult = await requireUserAuth()
if (isErrorResponse(authResult)) return authResult
@@ -48,6 +56,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const statuses = normalizeStatuses(query.getAll('status'))
const limitRaw = Number.parseInt(query.get('limit') || '50', 10)
const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 200) : 50
const activeOnlyQuery = statuses.length > 0 && statuses.every(isActiveRunStatus)
const scopedActiveRecoveryQuery =
activeOnlyQuery
&& !!workflowType
&& !!targetType
&& !!targetId
const runs = await listRuns({
userId: session.user.id,
projectId: projectId || undefined,
@@ -57,6 +71,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
episodeId: episodeId || undefined,
statuses: statuses.length > 0 ? statuses : undefined,
limit,
recoverableOnly: scopedActiveRecoveryQuery,
latestOnly: scopedActiveRecoveryQuery,
})
return NextResponse.json({ runs })
})

View File

@@ -5,7 +5,7 @@ import { AppIcon } from '@/components/ui/icons'
interface ImageGenerationInlineCountButtonProps {
prefix: ReactNode
suffix: ReactNode
suffix?: ReactNode
value: number
options: number[]
onValueChange: (value: number) => void
@@ -13,7 +13,11 @@ interface ImageGenerationInlineCountButtonProps {
disabled?: boolean
actionDisabled?: boolean
selectDisabled?: boolean
showCountControl?: boolean
splitInteractiveZones?: boolean
className?: string
actionClassName?: string
countClassName?: string
selectClassName?: string
labelClassName?: string
ariaLabel: string
@@ -29,7 +33,11 @@ export default function ImageGenerationInlineCountButton({
disabled = false,
actionDisabled,
selectDisabled,
showCountControl = true,
splitInteractiveZones = false,
className = '',
actionClassName = '',
countClassName = '',
selectClassName = '',
labelClassName = '',
ariaLabel,
@@ -42,6 +50,68 @@ export default function ImageGenerationInlineCountButton({
const selectStateClassName = isSelectDisabled
? 'pointer-events-none opacity-70'
: 'cursor-pointer'
const resolvedActionClassName = (actionClassName || className).trim()
if (!showCountControl) {
return (
<button
type="button"
onClick={() => {
if (isActionDisabled) return
onClick()
}}
disabled={isActionDisabled}
aria-label={ariaLabel}
className={`${resolvedActionClassName} ${rootStateClassName}`.trim()}
>
<span className={`${labelClassName} inline-flex items-center gap-1 whitespace-nowrap`.trim()}>{prefix}</span>
</button>
)
}
if (splitInteractiveZones) {
return (
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => {
if (isActionDisabled) return
onClick()
}}
disabled={isActionDisabled}
aria-label={ariaLabel}
className={`${resolvedActionClassName} ${rootStateClassName}`.trim()}
>
<span className={`${labelClassName} inline-flex items-center gap-1 whitespace-nowrap`.trim()}>{prefix}</span>
</button>
<span
className={`group relative inline-flex h-6 items-center gap-1 rounded-md px-1.5 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'
} ${countClassName}`.trim()}
>
<select
value={String(value)}
onChange={(event) => onValueChange(Number(event.target.value))}
aria-label={ariaLabel}
disabled={isSelectDisabled}
className={`${selectClassName} ${selectStateClassName}`.trim()}
>
{options.map((option) => (
<option key={option} value={option} className="text-black">
{option}
</option>
))}
</select>
<span className="pointer-events-none absolute inset-y-0 right-1 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<AppIcon name="chevronDown" className="h-3 w-3" />
</span>
{suffix ? (
<span className={`${labelClassName} whitespace-nowrap pr-4`.trim()}>{suffix}</span>
) : null}
</span>
</div>
)
}
return (
<div
@@ -61,10 +131,10 @@ export default function ImageGenerationInlineCountButton({
aria-disabled={isActionDisabled}
className={`${className} ${rootStateClassName}`.trim()}
>
<span className={labelClassName}>{prefix}</span>
<span className={`${labelClassName} inline-flex shrink-0 items-center whitespace-nowrap leading-none`.trim()}>{prefix}</span>
<span
className={`group relative inline-flex items-center rounded-md px-1.5 py-0.5 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/12 focus-within:bg-white/14'
className={`group relative inline-flex h-8 shrink-0 items-center rounded-full bg-white/12 px-2 transition-colors ${
isSelectDisabled ? '' : 'hover:bg-white/16 focus-within:bg-white/18'
}`}
onClick={(event: MouseEvent<HTMLSpanElement>) => event.stopPropagation()}
>
@@ -81,11 +151,11 @@ export default function ImageGenerationInlineCountButton({
</option>
))}
</select>
<span className="pointer-events-none absolute inset-y-0 right-1 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-current opacity-85 transition-colors group-hover:opacity-100 group-focus-within:opacity-100">
<AppIcon name="chevronDown" className="h-3 w-3" />
</span>
</span>
<span className={labelClassName}>{suffix}</span>
<span className={`${labelClassName} inline-flex shrink-0 items-center whitespace-nowrap leading-none`.trim()}>{suffix}</span>
</div>
)
}

View File

@@ -19,6 +19,7 @@ import {
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export interface LocationCreationModalProps {
mode: 'asset-hub' | 'project'
@@ -63,6 +64,7 @@ export function LocationCreationModal({
const [description, setDescription] = useState('')
const [aiInstruction, setAiInstruction] = useState('')
const [artStyle, setArtStyle] = useState('american-comic')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isAiDesigning, setIsAiDesigning] = useState(false)
@@ -119,6 +121,7 @@ export function LocationCreationModal({
? await aiDesignAssetHubLocation.mutateAsync(aiInstruction)
: await aiCreateProjectLocation.mutateAsync({ userInstruction: aiInstruction })
setDescription(data.prompt || '')
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
setAiInstruction('')
} catch (error: unknown) {
if (getErrorStatus(error) === 402) {
@@ -168,12 +171,14 @@ export function LocationCreationModal({
summary: body.description,
artStyle: body.artStyle,
folderId: body.folderId ?? null,
availableSlots,
})
} else {
await createProjectLocation.mutateAsync({
name: body.name,
description: body.description,
artStyle: body.artStyle,
availableSlots,
})
}
@@ -203,6 +208,7 @@ export function LocationCreationModal({
artStyle,
folderId: folderId ?? null,
count: locationGenerationCount,
availableSlots,
}) as CreatedLocationResponse
const createdLocationId = result.location?.id
if (!createdLocationId) {
@@ -219,6 +225,7 @@ export function LocationCreationModal({
description: description.trim(),
artStyle,
count: locationGenerationCount,
availableSlots,
}) as CreatedLocationResponse
const createdLocationId = result.location?.id
if (!createdLocationId) {

View File

@@ -14,6 +14,7 @@ import {
useUpdateProjectLocationDescription,
useUpdateProjectLocationName,
} from '@/lib/query/hooks'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export interface LocationEditModalProps {
mode: 'asset-hub' | 'project'
@@ -56,6 +57,7 @@ export function LocationEditModal({
const [editingName, setEditingName] = useState(locationName)
const [editingDescription, setEditingDescription] = useState(description || summary || '')
const [availableSlots, setAvailableSlots] = useState<LocationAvailableSlot[]>([])
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
const [isAiModifying, setIsAiModifying] = useState(false)
const [isSaving, setIsSaving] = useState(false)
@@ -113,6 +115,7 @@ export function LocationEditModal({
await updateAssetHubSummary.mutateAsync({
locationId,
summary: editingDescription,
availableSlots,
})
return
}
@@ -121,6 +124,7 @@ export function LocationEditModal({
locationId,
imageIndex: resolvedImageIndex,
description: editingDescription,
availableSlots,
})
}
@@ -139,6 +143,7 @@ export function LocationEditModal({
})
if (data?.modifiedDescription) {
setEditingDescription(data.modifiedDescription)
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(data.modifiedDescription)
setAiModifyInstruction('')
}
@@ -154,6 +159,7 @@ export function LocationEditModal({
const nextDescription = data?.modifiedDescription || data?.prompt || ''
if (nextDescription) {
setEditingDescription(nextDescription)
setAvailableSlots(Array.isArray(data.availableSlots) ? data.availableSlots : [])
onUpdate?.(nextDescription)
setAiModifyInstruction('')
}

View File

@@ -0,0 +1,124 @@
'use client'
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { AppIcon } from '@/components/ui/icons'
interface LongTextDetectionPromptCopy {
title: string
description: string
strongRecommend: string
smartSplitLabel: string
smartSplitBadge: string
continueLabel: string
continueHint: string
}
interface LongTextDetectionPromptProps {
open: boolean
copy: LongTextDetectionPromptCopy
onClose: () => void
onSmartSplit: () => void
onContinue: () => void
}
export default function LongTextDetectionPrompt({
open,
copy,
onClose,
onSmartSplit,
onContinue,
}: LongTextDetectionPromptProps) {
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open || typeof document === 'undefined') {
return null
}
return createPortal(
<div
className="fixed inset-0 z-[120] flex items-center justify-center glass-overlay p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<div className="glass-surface-modal w-full max-w-lg rounded-2xl border border-[var(--glass-stroke-base)] p-6 shadow-[0_20px_80px_-32px_rgba(15,23,42,0.45)]">
<div className="space-y-5">
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15))' }}
>
<AppIcon name="sparkles" className="h-5 w-5 text-[#7c3aed]" />
</div>
<h3 className="text-lg font-bold text-[var(--glass-text-primary)]">
{copy.title}
</h3>
</div>
<p className="text-sm leading-relaxed text-[var(--glass-text-secondary)]">
{copy.description}
</p>
<div
className="rounded-xl p-4 text-sm leading-relaxed"
style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(139,92,246,0.08))' }}
>
<p
className="font-semibold"
style={{
background: 'linear-gradient(135deg, #3b82f6, #7c3aed)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{copy.strongRecommend}
</p>
</div>
<div className="flex flex-col gap-3 pt-1">
<button
type="button"
onClick={onSmartSplit}
className="flex w-full items-center justify-center gap-2 rounded-xl py-3.5 text-base font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
style={{ background: 'linear-gradient(135deg, #3b82f6, #7c3aed)' }}
>
<AppIcon name="sparkles" className="h-5 w-5" />
<span>{copy.smartSplitLabel}</span>
<span className="rounded-full bg-white/20 px-2 py-0.5 text-xs">
{copy.smartSplitBadge}
</span>
</button>
<button
type="button"
onClick={onContinue}
className="w-full py-2.5 text-sm text-[var(--glass-text-tertiary)] transition-colors hover:text-[var(--glass-text-secondary)]"
>
{copy.continueLabel}
<span className="ml-1 text-xs opacity-60">
- {copy.continueHint}
</span>
</button>
</div>
</div>
</div>
</div>,
document.body,
)
}

View File

@@ -109,7 +109,7 @@ export default function StoryInputComposer({
return (
<div className="relative w-full glass-surface-elevated rounded-2xl">
<div className="p-6 pb-0">
<div className="p-6 pb-4">
{topRight && (
<div className="mb-3 flex items-center justify-end">
{topRight}

View File

@@ -133,7 +133,7 @@ export function CapsuleNav({ items, activeId, onItemClick, projectId, episodeId
}
return (
<nav className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fadeInDown">
<nav className="fixed top-20 left-1/2 -translate-x-1/2 z-40 animate-fadeInDown">
<div
className="flex rounded-full px-2 py-1"
style={{
@@ -215,7 +215,7 @@ export function EpisodeSelector({
if (!currentEp) return null
return (
<div className="fixed top-20 left-6 z-[60]" ref={menuRef}>
<div className="fixed top-20 left-6 z-40" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="glass-btn-base glass-btn-secondary flex items-center gap-3 px-4 py-3 transition-all group"

View File

@@ -0,0 +1,22 @@
interface ApiErrorPayload {
error?: string | { message?: string | null } | null
message?: string | null
}
export async function readApiErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const payload = await response.json() as ApiErrorPayload
if (typeof payload?.error === 'string' && payload.error.trim()) {
return payload.error
}
if (payload?.error && typeof payload.error === 'object' && typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message
}
if (typeof payload?.message === 'string' && payload.message.trim()) {
return payload.message
}
} catch {
// Keep the explicit fallback when the backend does not return JSON.
}
return fallback
}

View File

@@ -6,6 +6,11 @@ import { logError as _ulogError } from '@/lib/logging/core'
import { executeAiTextStep } from '@/lib/ai-runtime'
import { withTextBilling } from '@/lib/billing'
import { safeParseJsonObject } from '@/lib/json-repair'
import {
type LocationAvailableSlot,
normalizeLocationAvailableSlots,
} from '@/lib/location-available-slots'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import type { Locale } from '@/i18n/routing'
@@ -26,6 +31,7 @@ export interface AIDesignOptions {
export interface AIDesignResult {
success: boolean
prompt?: string
availableSlots?: LocationAvailableSlot[]
error?: string
}
@@ -111,22 +117,12 @@ export async function aiDesign(options: AIDesignOptions): Promise<AIDesignResult
}
// 解析 JSON 响应
let parsedResponse
let parsedResponse: Record<string, unknown>
try {
parsedResponse = JSON.parse(aiResponse)
parsedResponse = safeParseJsonObject(aiResponse)
} catch {
const jsonMatch = aiResponse.match(/\{[\s\S]*\}/)
if (jsonMatch) {
try {
parsedResponse = JSON.parse(jsonMatch[0])
} catch {
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
return { success: false, error: 'AI返回格式错误' }
}
} else {
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
return { success: false, error: 'AI返回格式错误' }
}
_ulogError('[AI Design] AI 响应解析失败:', aiResponse)
return { success: false, error: 'AI返回格式错误' }
}
if (!parsedResponse.prompt) {
@@ -135,6 +131,9 @@ export async function aiDesign(options: AIDesignOptions): Promise<AIDesignResult
return {
success: true,
prompt: parsedResponse.prompt
prompt: typeof parsedResponse.prompt === 'string' ? parsedResponse.prompt : '',
availableSlots: assetType === 'location'
? normalizeLocationAvailableSlots(parsedResponse.available_slots)
: [],
}
}

View File

@@ -17,6 +17,10 @@ import { deleteObject } from '@/lib/storage'
import { resolveStorageKeyFromMediaValue } from '@/lib/media/service'
import { updateCharacterAppearanceLabels, updateLocationImageLabels } from '@/lib/image-label'
import type { AssetKind, AssetScope } from '@/lib/assets/contracts'
import {
normalizeLocationAvailableSlots,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
import {
createGlobalLocationBackedAsset,
createProjectLocationBackedAsset,
@@ -849,6 +853,7 @@ async function copyLocationFromGlobal(input: AssetCopyInput) {
locationId: input.targetId,
imageIndex: image.imageIndex,
description: image.description,
availableSlots: image.availableSlots,
imageUrl: labelUpdate?.imageUrl || image.imageUrl,
isSelected: image.isSelected,
},
@@ -921,6 +926,16 @@ async function updateGlobalAsset(input: AssetUpdateInput) {
where: { id: input.assetId },
data: updateData,
})
if (input.body.availableSlots !== undefined) {
await prisma.globalLocationImage.updateMany({
where: { locationId: input.assetId },
data: {
availableSlots: stringifyLocationAvailableSlots(
normalizeLocationAvailableSlots(input.body.availableSlots),
),
},
})
}
return { success: true, location }
}
if (input.kind === 'prop') {

View File

@@ -1,4 +1,10 @@
import { buildCharactersIntroduction } from '@/lib/constants'
import {
formatLocationAvailableSlotsText,
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
type PromptLocale = 'zh' | 'en'
export type ClipCharacterRef = string | { name?: string | null }
@@ -20,6 +26,7 @@ export type PromptLocationAsset = {
images?: Array<{
isSelected?: boolean
description?: string | null
availableSlots?: string | null
}>
}
@@ -35,6 +42,7 @@ export type PromptAssetContextInput = {
clipCharacters: ClipCharacterRef[]
clipLocation: string | null
clipProps: string[]
locale?: PromptLocale
}
export type PromptAssetContext = {
@@ -134,7 +142,14 @@ export function buildPromptAssetContext(input: PromptAssetContextInput): PromptA
? input.locations.find((location) => normalizeName(location.name) === normalizeName(environmentName))
: null
const selectedImage = matchedLocation?.images?.find((image) => image.isSelected) ?? matchedLocation?.images?.[0]
const locationDescriptionText = selectedImage?.description || '无'
const locationDescription = selectedImage?.description || '无'
const locationSlotsText = formatLocationAvailableSlotsText(
parseLocationAvailableSlots(selectedImage?.availableSlots),
input.locale ?? 'zh',
)
const locationDescriptionText = locationSlotsText
? `${locationDescription}\n\n${locationSlotsText}`
: locationDescription
return {
subjectNames,

View File

@@ -1,6 +1,10 @@
import { randomUUID } from 'crypto'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
type LocationAvailableSlot,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
export type LocationBackedAssetKind = 'location' | 'prop'
@@ -28,6 +32,7 @@ type LocationBackedImageRow = {
id: string
imageIndex: number
description: string | null
availableSlots: string | null
imageUrl: string | null
imageMediaId: string | null
previousImageUrl: string | null
@@ -88,6 +93,7 @@ async function readProjectLocationBackedImages(locationIds: string[]): Promise<M
id,
imageIndex,
description,
availableSlots,
imageUrl,
imageMediaId,
previousImageUrl,
@@ -111,6 +117,7 @@ async function readGlobalLocationBackedImages(locationIds: string[]): Promise<Ma
id,
imageIndex,
description,
availableSlots,
imageUrl,
imageMediaId,
previousImageUrl,
@@ -214,6 +221,7 @@ export async function createProjectLocationBackedAsset(input: {
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
availableSlots: [],
})
return { id }
}
@@ -254,6 +262,7 @@ export async function createGlobalLocationBackedAsset(input: {
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
availableSlots: [],
})
return { id }
}
@@ -262,17 +271,31 @@ export async function seedProjectLocationBackedImageSlots(input: {
locationId: string
fallbackDescription: string
descriptions?: string[]
availableSlots?: LocationAvailableSlot[]
locationImageModel?: {
createMany: (args: {
data: Array<{
locationId: string
imageIndex: number
description: string
availableSlots: string
}>
}) => Promise<unknown>
}
}): Promise<void> {
const descriptions = normalizeSeedDescriptions(input)
if (descriptions.length === 0) {
return
}
const availableSlots = stringifyLocationAvailableSlots(input.availableSlots ?? [])
await prisma.locationImage.createMany({
const locationImageModel = input.locationImageModel ?? prisma.locationImage
await locationImageModel.createMany({
data: descriptions.map((description, imageIndex) => ({
locationId: input.locationId,
imageIndex,
description,
availableSlots,
})),
})
}
@@ -281,17 +304,20 @@ export async function seedGlobalLocationBackedImageSlots(input: {
locationId: string
fallbackDescription: string
descriptions?: string[]
availableSlots?: LocationAvailableSlot[]
}): Promise<void> {
const descriptions = normalizeSeedDescriptions(input)
if (descriptions.length === 0) {
return
}
const availableSlots = stringifyLocationAvailableSlots(input.availableSlots ?? [])
await prisma.globalLocationImage.createMany({
data: descriptions.map((description, imageIndex) => ({
locationId: input.locationId,
imageIndex,
description,
availableSlots,
})),
})
}

View File

@@ -1,6 +1,4 @@
interface ApiErrorPayload {
error?: string | { message?: string } | null
}
import { readApiErrorMessage } from '@/lib/api/read-error-message'
interface ProjectCreationPayload {
project?: {
@@ -56,21 +54,6 @@ function readNestedString(
return typeof value === 'string' && value.trim() ? value : null
}
async function readApiErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const payload = await response.json() as ApiErrorPayload
if (typeof payload?.error === 'string' && payload.error.trim()) {
return payload.error
}
if (payload?.error && typeof payload.error === 'object' && typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message
}
} catch {
// Keep the explicit fallback when the backend does not return JSON.
}
return fallback
}
async function readProjectId(response: Response): Promise<string> {
const payload = await response.json() as ProjectCreationPayload
const projectId = readNestedString(readObject(payload), 'project', 'id')
@@ -112,7 +95,6 @@ export async function createHomeProjectLaunch({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: projectName,
description: storyText,
}),
})

View File

@@ -0,0 +1,45 @@
export type LocationAvailableSlot = string
export type LocationSlotLocale = 'zh' | 'en'
function normalizeText(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export function normalizeLocationAvailableSlots(value: unknown): LocationAvailableSlot[] {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const slots: LocationAvailableSlot[] = []
for (const item of value) {
const normalized = normalizeText(item)
if (!normalized || seen.has(normalized)) continue
seen.add(normalized)
slots.push(normalized)
}
return slots
}
export function parseLocationAvailableSlots(raw: string | null | undefined): LocationAvailableSlot[] {
if (!raw) return []
try {
return normalizeLocationAvailableSlots(JSON.parse(raw))
} catch {
return []
}
}
export function stringifyLocationAvailableSlots(slots: LocationAvailableSlot[]): string {
return JSON.stringify(normalizeLocationAvailableSlots(slots))
}
export function formatLocationAvailableSlotsText(
slots: LocationAvailableSlot[],
locale: LocationSlotLocale = 'zh',
): string {
const normalized = normalizeLocationAvailableSlots(slots)
if (normalized.length === 0) return ''
const lines = normalized.map((slot) => `- ${slot}`)
const header = locale === 'en' ? 'Available character slots:' : '可站位置:'
return `${header}\n${lines.join('\n')}`
}

View File

@@ -0,0 +1,32 @@
import {
formatLocationAvailableSlotsText,
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
type Locale = 'zh' | 'en'
export function buildLocationImagePromptCore(params: {
description: string
availableSlotsRaw?: string | null
locale: Locale
}): string {
const promptBody = params.description.trim()
const slotText = formatLocationAvailableSlotsText(
parseLocationAvailableSlots(params.availableSlotsRaw),
params.locale,
)
const spatialConstraints = params.locale === 'en'
? 'Use a wide, complete environment composition that clearly shows the main structure, foreground/midground/background, and visible spatial boundaries. Every anchor object or anchor area implied by the available slots must be clearly visible in the frame, and each slot must still have enough open space around it for a character to be placed there later. Do not generate a generic partial background, cropped anchor, or ambiguous layout that makes the slot positions unusable.'
: '必须使用宽广完整的场景全景构图,清楚展示主要结构、前景/中景/背景和空间边界。可站位置中提到的关键锚物或区域必须在画面中清晰可见,且每个位置附近都要保留足够的空白区域,方便后续角色落位。禁止生成局部裁切、锚点缺失、空间关系模糊的泛化背景。'
if (!slotText) {
return `${promptBody}\n\n${spatialConstraints}`.trim()
}
const slotHeader = params.locale === 'en'
? 'This scene must clearly support the following fixed character positions:'
: '该场景必须清楚支持以下固定人物位置:'
return `${promptBody}\n\n${slotHeader}\n${slotText}\n\n${spatialConstraints}`.trim()
}

View File

@@ -0,0 +1,46 @@
import {
formatLocationAvailableSlotsText,
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
type PromptLocationImage = {
isSelected?: boolean
description?: string | null
availableSlots?: string | null
}
type PromptLocationAsset = {
name: string
images?: PromptLocationImage[]
}
type Locale = 'zh' | 'en'
export function buildInsertPanelLocationsDescription(
locations: PromptLocationAsset[],
relatedLocations: string[],
locale: Locale = 'zh',
): string {
const filteredLocations = locations.filter(
(location) => relatedLocations.length === 0 || relatedLocations.includes(location.name),
)
if (filteredLocations.length === 0) {
return '无'
}
return filteredLocations
.map((location) => {
const selectedImage = location.images?.find((image) => image.isSelected) ?? location.images?.[0]
const description = selectedImage?.description || '无描述'
const slotsText = formatLocationAvailableSlotsText(
parseLocationAvailableSlots(selectedImage?.availableSlots),
locale,
)
return slotsText
? `${location.name}: ${description}\n${slotsText}`
: `${location.name}: ${description}`
})
.join('\n')
}

View File

@@ -1,6 +1,7 @@
export interface PanelCharacterRef {
name: string
appearance: string
slot?: string
}
type JsonRecord = Record<string, unknown>

View File

@@ -70,6 +70,7 @@ export type ClipStoryboardPanels = {
export type ScriptToStoryboardOrchestratorInput = {
concurrency?: number
locale?: 'zh' | 'en'
clips: ClipInput[]
novelPromotionData: {
characters: CharacterAsset[]
@@ -303,20 +304,26 @@ export async function runScriptToStoryboardOrchestrator(
const phase2ActingByClipId = new Map<string, ActingDirection[]>()
const phase3PanelsByClipId = new Map<string, StoryboardPanel[]>()
const phase1Results = await mapWithConcurrency(
const clipPanels = await mapWithConcurrency(
clips,
concurrency,
async (clip, i) => {
const clipIndex = i + 1
async (clip, index): Promise<ClipStoryboardPanels> => {
const clipIndex = index + 1
const clipContent = typeof clip.content === 'string' ? clip.content.trim() : ''
if (!clipContent) {
throw new Error(`Clip ${formatClipId(clip)} content is empty`)
}
const clipCharacters = parseClipCharacters(clip.characters)
const clipLocation = clip.location || null
const clipProps = parseClipProps(clip.props ?? null)
const filteredAppearanceList = getFilteredAppearanceList(novelPromotionData.characters || [], clipCharacters)
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters || [], clipCharacters)
const filteredPropDescription = compileAssetPromptFragments(buildPromptAssetContext({
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations || [],
clipLocation,
input.locale ?? 'zh',
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],
props: novelPromotionData.props || [],
@@ -342,7 +349,7 @@ export async function runScriptToStoryboardOrchestrator(
.replace('{characters_introduction}', charactersIntroduction)
.replace('{characters_appearance_list}', filteredAppearanceList)
.replace('{characters_full_description}', filteredFullDescription)
.replace('{props_description}', filteredPropDescription)
.replace('{props_description}', filteredPropsDescription)
.replace('{clip_json}', clipJson)
const screenplay = parseScreenplay(clip.screenplay)
@@ -373,44 +380,7 @@ export async function runScriptToStoryboardOrchestrator(
return panels
},
)
return {
clipId: clip.id,
planPanels,
}
},
)
for (const result of phase1Results) {
phase1PanelsByClipId.set(result.clipId, result.planPanels)
}
const clipPanels = await mapWithConcurrency(
clips,
concurrency,
async (clip, index): Promise<ClipStoryboardPanels> => {
const clipIndex = index + 1
const clipCharacters = parseClipCharacters(clip.characters)
const clipLocation = clip.location || null
const clipProps = parseClipProps(clip.props ?? null)
const planPanels = phase1PanelsByClipId.get(clip.id) || []
if (planPanels.length === 0) {
throw new Error(`Missing phase1 result for clip ${formatClipId(clip)}`)
}
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters || [], clipCharacters)
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations || [],
clipLocation,
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],
props: novelPromotionData.props || [],
clipCharacters: [],
clipLocation: null,
clipProps,
})).propsDescriptionText
phase1PanelsByClipId.set(clip.id, planPanels)
const phase2Meta = withStepMeta(
`clip_${clip.id}_phase2_cinematography`,

View File

@@ -0,0 +1,74 @@
export type StageArtifactReadiness = {
hasStory: boolean
hasScript: boolean
hasStoryboard: boolean
hasVideo: boolean
hasVoice: boolean
}
type EpisodeClipLike = {
screenplay?: string | null
[key: string]: unknown
}
type StoryboardPanelLike = {
videoUrl?: string | null
[key: string]: unknown
}
type StoryboardLike = {
panels?: StoryboardPanelLike[] | null
[key: string]: unknown
}
type EpisodeLike = {
novelText?: string | null
clips?: unknown[] | null
storyboards?: unknown[] | null
voiceLines?: unknown[] | null
}
function hasNonEmptyText(value: string | null | undefined) {
return typeof value === 'string' && value.trim().length > 0
}
function isEpisodeClipLike(value: unknown): value is EpisodeClipLike {
return typeof value === 'object' && value !== null
}
function isStoryboardPanelLike(value: unknown): value is StoryboardPanelLike {
return typeof value === 'object' && value !== null
}
function isStoryboardLike(value: unknown): value is StoryboardLike {
return typeof value === 'object' && value !== null
}
export function hasScriptArtifacts(clips: unknown[] | null | undefined) {
if (!Array.isArray(clips) || clips.length === 0) return false
return clips.some((clip) => isEpisodeClipLike(clip) && hasNonEmptyText(clip.screenplay))
}
export function hasStoryboardArtifacts(storyboards: unknown[] | null | undefined) {
if (!Array.isArray(storyboards) || storyboards.length === 0) return false
return storyboards.some((storyboard) => isStoryboardLike(storyboard)
&& Array.isArray(storyboard.panels)
&& storyboard.panels.some((panel) => isStoryboardPanelLike(panel)))
}
export function hasVideoArtifacts(storyboards: unknown[] | null | undefined) {
if (!Array.isArray(storyboards) || storyboards.length === 0) return false
return storyboards.some((storyboard) => isStoryboardLike(storyboard)
&& Array.isArray(storyboard.panels)
&& storyboard.panels.some((panel) => isStoryboardPanelLike(panel) && hasNonEmptyText(panel.videoUrl)))
}
export function resolveEpisodeStageArtifacts(episode: EpisodeLike | null | undefined): StageArtifactReadiness {
return {
hasStory: hasNonEmptyText(episode?.novelText),
hasScript: hasScriptArtifacts(episode?.clips),
hasStoryboard: hasStoryboardArtifacts(episode?.storyboards),
hasVideo: hasVideoArtifacts(episode?.storyboards),
hasVoice: Array.isArray(episode?.voiceLines) && episode.voiceLines.length > 0,
}
}

View File

@@ -0,0 +1,11 @@
function padTwoDigits(value: number): string {
return String(value).padStart(2, '0')
}
export function formatDefaultProjectTimestamp(date: Date): string {
const month = padTwoDigits(date.getMonth() + 1)
const day = padTwoDigits(date.getDate())
const hours = padTwoDigits(date.getHours())
const minutes = padTwoDigits(date.getMinutes())
return `${month}-${day} ${hours}:${minutes}`
}

View File

@@ -0,0 +1,82 @@
import type { Locale } from '@/i18n/routing'
export const PROJECT_NAME_MAX_LENGTH = 100
export const PROJECT_DESCRIPTION_MAX_LENGTH = 500
export interface ProjectDraftInput {
name: string
description?: string | null
}
export interface NormalizedProjectDraft {
name: string
description: string | null
}
export type ProjectValidationCode =
| 'PROJECT_NAME_REQUIRED'
| 'PROJECT_NAME_TOO_LONG'
| 'PROJECT_DESCRIPTION_TOO_LONG'
export interface ProjectValidationIssue {
code: ProjectValidationCode
field: 'name' | 'description'
limit?: number
}
function normalizeNullableText(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed ? trimmed : null
}
export function normalizeProjectDraft(input: ProjectDraftInput): NormalizedProjectDraft {
return {
name: input.name.trim(),
description: normalizeNullableText(input.description),
}
}
export function validateProjectDraft(input: ProjectDraftInput): ProjectValidationIssue | null {
const normalized = normalizeProjectDraft(input)
if (!normalized.name) {
return {
code: 'PROJECT_NAME_REQUIRED',
field: 'name',
}
}
if (normalized.name.length > PROJECT_NAME_MAX_LENGTH) {
return {
code: 'PROJECT_NAME_TOO_LONG',
field: 'name',
limit: PROJECT_NAME_MAX_LENGTH,
}
}
if (normalized.description && normalized.description.length > PROJECT_DESCRIPTION_MAX_LENGTH) {
return {
code: 'PROJECT_DESCRIPTION_TOO_LONG',
field: 'description',
limit: PROJECT_DESCRIPTION_MAX_LENGTH,
}
}
return null
}
export function formatProjectValidationIssue(issue: ProjectValidationIssue, locale: Locale): string {
switch (issue.code) {
case 'PROJECT_NAME_REQUIRED':
return locale === 'en' ? 'Project name is required.' : '项目名称不能为空。'
case 'PROJECT_NAME_TOO_LONG':
return locale === 'en'
? `Project name cannot exceed ${PROJECT_NAME_MAX_LENGTH} characters.`
: `项目名称不能超过 ${PROJECT_NAME_MAX_LENGTH} 个字符。`
case 'PROJECT_DESCRIPTION_TOO_LONG':
return locale === 'en'
? `Project description cannot exceed ${PROJECT_DESCRIPTION_MAX_LENGTH} characters.`
: `项目描述不能超过 ${PROJECT_DESCRIPTION_MAX_LENGTH} 个字符。`
}
}

View File

@@ -0,0 +1,90 @@
'use client'
const PROBE_SUCCESS_COOLDOWN_MS = 60_000
const PROBE_RETRY_INTERVAL_MS = 2_000
const successfulProbeScopes = new Map<string, number>()
type RecoveryProbeContext = {
projectId: string
storageScopeKey?: string
}
type StartRecoveryProbeArgs = {
projectId: string
storageKey: string
storageScopeKey?: string
hasRunState: () => boolean
resolveActiveRunId: (context: RecoveryProbeContext) => Promise<string | null>
onRecovered: (runId: string) => void
}
function scheduleProbe(
callback: () => void,
delayMs: number,
): ReturnType<typeof setTimeout> {
return globalThis.setTimeout(callback, delayMs)
}
export function startRecoveryProbe(args: StartRecoveryProbeArgs): () => void {
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
const clearRetryTimer = () => {
if (retryTimer) {
globalThis.clearTimeout(retryTimer)
retryTimer = null
}
}
const scheduleRetry = (delayMs: number) => {
if (cancelled || args.hasRunState()) return
clearRetryTimer()
retryTimer = scheduleProbe(() => {
void probe()
}, delayMs)
}
const probe = async () => {
if (cancelled || args.hasRunState()) return
const lastSuccessAt = successfulProbeScopes.get(args.storageKey)
if (lastSuccessAt) {
const cooldownRemainingMs =
PROBE_SUCCESS_COOLDOWN_MS - (Date.now() - lastSuccessAt)
if (cooldownRemainingMs > 0) {
scheduleRetry(cooldownRemainingMs)
return
}
}
const activeRunId = await args.resolveActiveRunId({
projectId: args.projectId,
storageScopeKey: args.storageScopeKey,
}).catch(() => null)
if (cancelled || args.hasRunState()) return
if (!activeRunId) {
scheduleRetry(PROBE_RETRY_INTERVAL_MS)
return
}
successfulProbeScopes.set(args.storageKey, Date.now())
args.onRecovered(activeRunId)
}
void probe()
return () => {
cancelled = true
clearRetryTimer()
}
}
export const recoveryProbeTestUtils = {
clearSuccessfulProbeScopes() {
successfulProbeScopes.clear()
},
PROBE_RETRY_INTERVAL_MS,
PROBE_SUCCESS_COOLDOWN_MS,
}

View File

@@ -3,12 +3,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { applyRunStreamEvent } from './state-machine'
import { clearRunSnapshot, loadRunSnapshot, saveRunSnapshot } from './snapshot'
import { subscribeRecoveredRun } from './recovered-run-subscription'
import { executeRunRequest } from './run-request-executor'
import { deriveRunStreamView } from './run-stream-view'
import type { RunResult, RunState, RunStreamView, UseRunStreamStateOptions } from './types'
import { apiFetch } from '@/lib/api-fetch'
import { startRecoveryProbe } from './recovery-probe'
export type {
RunResult,
@@ -18,8 +18,6 @@ export type {
} from './types'
const TASK_STREAM_TIMEOUT_MS = 1000 * 60 * 30
const PROBE_COOLDOWN_MS = 60_000
const probedScopes = new Map<string, number>()
export function useRunStreamState<TParams extends Record<string, unknown>>(
options: UseRunStreamStateOptions<TParams>,
@@ -40,7 +38,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
const [isRecoveredRunning, setIsRecoveredRunning] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const finalResultRef = useRef<RunResult | null>(null)
const hydratedStorageKeyRef = useRef<string | null>(null)
const resolveActiveRunIdRef = useRef(resolveActiveRunId)
const storageKey = useMemo(() => {
if (storageScopeKey) {
@@ -61,60 +58,40 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
resolveActiveRunIdRef.current = resolveActiveRunId
}, [resolveActiveRunId])
useEffect(() => {
if (!projectId) return
if (hydratedStorageKeyRef.current === storageKey) return
hydratedStorageKeyRef.current = storageKey
const snapshotRunState = loadRunSnapshot(storageKey)
if (!snapshotRunState) return
setRunState(snapshotRunState)
if (snapshotRunState.status === 'running') {
setIsRecoveredRunning(true)
}
}, [projectId, storageKey])
useEffect(() => {
if (!projectId || !resolveActiveRunIdRef.current) return
const lastProbed = probedScopes.get(storageKey)
if (lastProbed && Date.now() - lastProbed < PROBE_COOLDOWN_MS) return
probedScopes.set(storageKey, Date.now())
if (runStateRef.current) return
const existingSnapshot = loadRunSnapshot(storageKey)
if (existingSnapshot) return
let cancelled = false
void (async () => {
const activeRunId = await resolveActiveRunIdRef.current?.({
projectId,
storageScopeKey,
}).catch(() => null)
if (cancelled || !activeRunId) return
const now = Date.now()
setRunState((prev) => {
if (prev) return prev
return {
runId: activeRunId,
status: 'running',
startedAt: now,
updatedAt: now,
terminalAt: null,
errorMessage: '',
summary: null,
payload: null,
stepsById: {},
stepOrder: [],
activeStepId: null,
selectedStepId: null,
}
})
setIsRecoveredRunning(true)
})()
return () => {
cancelled = true
}
return startRecoveryProbe({
projectId,
storageKey,
storageScopeKey,
hasRunState: () => runStateRef.current !== null,
resolveActiveRunId: (context) =>
resolveActiveRunIdRef.current?.(context) ?? Promise.resolve(null),
onRecovered: (activeRunId) => {
const now = Date.now()
setRunState((prev) => {
if (prev) return prev
return {
runId: activeRunId,
status: 'running',
startedAt: now,
updatedAt: now,
terminalAt: null,
errorMessage: '',
summary: null,
payload: null,
stepsById: {},
stepOrder: [],
activeStepId: null,
selectedStepId: null,
}
})
setIsRecoveredRunning(true)
},
})
}, [projectId, storageKey, storageScopeKey])
useEffect(() => {
@@ -150,11 +127,6 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
}
}, [isRecoveredRunning, runState, runState?.status])
useEffect(() => {
if (!projectId) return
saveRunSnapshot(storageKey, runState)
}, [projectId, runState, storageKey])
const run = useCallback(
async (params: TParams): Promise<RunResult> => {
if (!projectId) {
@@ -270,8 +242,7 @@ export function useRunStreamState<TParams extends Record<string, unknown>>(
setRunState(null)
finalResultRef.current = null
setIsRecoveredRunning(false)
clearRunSnapshot(storageKey)
}, [storageKey, stop])
}, [stop])
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 500)

View File

@@ -1,58 +0,0 @@
import type { RunState } from './types'
export const SNAPSHOT_TTL_MS = 1000 * 60 * 60 * 6
type RunSnapshot = {
savedAt: number
runState: RunState
}
export function loadRunSnapshot(storageKey: string): RunState | null {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(storageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as RunSnapshot
if (!parsed || typeof parsed !== 'object') {
window.sessionStorage.removeItem(storageKey)
return null
}
if (typeof parsed.savedAt !== 'number' || Date.now() - parsed.savedAt > SNAPSHOT_TTL_MS) {
window.sessionStorage.removeItem(storageKey)
return null
}
const snapshotRunState = parsed.runState
if (!snapshotRunState || typeof snapshotRunState !== 'object' || typeof snapshotRunState.runId !== 'string') {
window.sessionStorage.removeItem(storageKey)
return null
}
return snapshotRunState
} catch {
try {
window.sessionStorage.removeItem(storageKey)
} catch { }
return null
}
}
export function saveRunSnapshot(storageKey: string, runState: RunState | null) {
if (typeof window === 'undefined') return
try {
if (!runState) {
window.sessionStorage.removeItem(storageKey)
return
}
const snapshot: RunSnapshot = {
savedAt: Date.now(),
runState,
}
window.sessionStorage.setItem(storageKey, JSON.stringify(snapshot))
} catch { }
}
export function clearRunSnapshot(storageKey: string) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.removeItem(storageKey)
} catch { }
}

View File

@@ -3,6 +3,7 @@
import { useRunStreamState, type RunResult } from './useRunStreamState'
import { TASK_TYPE } from '@/lib/task/types'
import { apiFetch } from '@/lib/api-fetch'
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
export type ScriptToStoryboardRunParams = {
episodeId: string
@@ -46,13 +47,26 @@ export function useScriptToStoryboardRunStream({ projectId, episodeId }: UseScri
if (!response.ok) return null
const data = await response.json().catch(() => null)
const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)
? (data as { runs: Array<{ id?: unknown; targetType?: unknown; targetId?: unknown; status?: unknown }> }).runs
? (data as {
runs: Array<{
id?: unknown
status?: unknown
createdAt?: unknown
updatedAt?: unknown
leaseExpiresAt?: unknown
heartbeatAt?: unknown
}>
}).runs
: []
for (const run of runs) {
if (!run || typeof run.id !== 'string' || !run.id) continue
return run.id
}
return null
const decision = selectRecoverableRun(runs.map((run) => ({
id: typeof run?.id === 'string' ? run.id : null,
status: typeof run?.status === 'string' ? run.status : null,
createdAt: typeof run?.createdAt === 'string' ? run.createdAt : null,
updatedAt: typeof run?.updatedAt === 'string' ? run.updatedAt : null,
leaseExpiresAt: typeof run?.leaseExpiresAt === 'string' ? run.leaseExpiresAt : null,
heartbeatAt: typeof run?.heartbeatAt === 'string' ? run.heartbeatAt : null,
})))
return decision.runId
},
validateParams: (params) => {
if (!params.episodeId) {

View File

@@ -3,6 +3,7 @@
import { useRunStreamState, type RunResult } from './useRunStreamState'
import { TASK_TYPE } from '@/lib/task/types'
import { apiFetch } from '@/lib/api-fetch'
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
export type StoryToScriptRunParams = {
episodeId: string
@@ -47,13 +48,26 @@ export function useStoryToScriptRunStream({ projectId, episodeId }: UseStoryToSc
if (!response.ok) return null
const data = await response.json().catch(() => null)
const runs = data && typeof data === 'object' && Array.isArray((data as { runs?: unknown[] }).runs)
? (data as { runs: Array<{ id?: unknown; targetType?: unknown; targetId?: unknown; status?: unknown }> }).runs
? (data as {
runs: Array<{
id?: unknown
status?: unknown
createdAt?: unknown
updatedAt?: unknown
leaseExpiresAt?: unknown
heartbeatAt?: unknown
}>
}).runs
: []
for (const run of runs) {
if (!run || typeof run.id !== 'string' || !run.id) continue
return run.id
}
return null
const decision = selectRecoverableRun(runs.map((run) => ({
id: typeof run?.id === 'string' ? run.id : null,
status: typeof run?.status === 'string' ? run.status : null,
createdAt: typeof run?.createdAt === 'string' ? run.createdAt : null,
updatedAt: typeof run?.updatedAt === 'string' ? run.updatedAt : null,
leaseExpiresAt: typeof run?.leaseExpiresAt === 'string' ? run.leaseExpiresAt : null,
heartbeatAt: typeof run?.heartbeatAt === 'string' ? run.heartbeatAt : null,
})))
return decision.runId
},
validateParams: (params) => {
if (!params.episodeId) {

View File

@@ -8,6 +8,7 @@ import {
invalidateGlobalCharacters,
invalidateGlobalLocations,
} from './asset-hub-mutations-shared'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export function useAiDesignLocation() {
return useMutation({
@@ -21,7 +22,7 @@ export function useAiDesignLocation() {
},
'Failed to design location',
)
return resolveTaskResponse<{ prompt?: string }>(response)
return resolveTaskResponse<{ prompt?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -37,6 +38,7 @@ export function useCreateAssetHubLocation() {
folderId: string | null
artStyle: string
count?: number
availableSlots?: LocationAvailableSlot[]
}) => {
return await requestJsonWithError('/api/asset-hub/locations', {
method: 'POST',

View File

@@ -9,6 +9,7 @@ import {
invalidateGlobalCharacters,
invalidateGlobalLocations,
} from './asset-hub-mutations-shared'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
export function useUpdateCharacterName() {
const queryClient = useQueryClient()
@@ -134,9 +135,11 @@ export function useUpdateLocationSummary() {
mutationFn: async ({
locationId,
summary,
availableSlots,
}: {
locationId: string
summary: string
availableSlots?: LocationAvailableSlot[]
}) => {
return await requestJsonWithError(`/api/assets/${locationId}`, {
method: 'PATCH',
@@ -145,6 +148,7 @@ export function useUpdateLocationSummary() {
scope: 'global',
kind: 'location',
summary,
...(availableSlots ? { availableSlots } : {}),
}),
}, 'Failed to update location summary')
},
@@ -179,7 +183,7 @@ export function useAiModifyCharacterDescription() {
},
'Failed to modify character description',
)
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
return resolveTaskResponse<{ modifiedDescription?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -211,7 +215,7 @@ export function useAiModifyLocationDescription() {
},
'Failed to modify location description',
)
return resolveTaskResponse<{ modifiedDescription?: string }>(response)
return resolveTaskResponse<{ modifiedDescription?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}

View File

@@ -3,6 +3,7 @@ import { logError as _ulogError } from '@/lib/logging/core'
import type { Project } from '@/types/project'
import { queryKeys } from '../keys'
import type { ProjectAssetsData } from '../hooks/useProjectAssets'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
import { resolveTaskResponse } from '@/lib/task/client'
import { apiFetch } from '@/lib/api-fetch'
import {
@@ -149,10 +150,12 @@ export function useUpdateProjectLocationDescription(projectId: string) {
locationId,
description,
imageIndex,
availableSlots,
}: {
locationId: string
description: string
imageIndex?: number
availableSlots?: LocationAvailableSlot[]
}) => {
return await requestJsonWithError(`/api/novel-promotion/${projectId}/location`, {
method: 'PATCH',
@@ -161,6 +164,7 @@ export function useUpdateProjectLocationDescription(projectId: string) {
locationId,
imageIndex: typeof imageIndex === 'number' ? imageIndex : 0,
description,
...(availableSlots ? { availableSlots } : {}),
}),
}, 'Failed to update location description')
},
@@ -199,7 +203,7 @@ export function useAiModifyProjectLocationDescription(projectId: string) {
},
'Failed to modify location description',
)
return resolveTaskResponse<{ prompt?: string; modifiedDescription?: string }>(response)
return resolveTaskResponse<{ prompt?: string; modifiedDescription?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -220,7 +224,7 @@ export function useAiCreateProjectLocation(projectId: string) {
},
'Failed to design location',
)
return await resolveTaskResponse<{ prompt?: string }>(response)
return await resolveTaskResponse<{ prompt?: string; availableSlots?: LocationAvailableSlot[] }>(response)
},
})
}
@@ -240,6 +244,7 @@ export function useCreateProjectLocation(projectId: string) {
description: string
artStyle?: string
count?: number
availableSlots?: LocationAvailableSlot[]
}) =>
await requestJsonWithError(
`/api/novel-promotion/${projectId}/location`,

View File

@@ -0,0 +1,305 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { TASK_STATUS } from '@/lib/task/types'
import { RUN_STATUS, RUN_STEP_STATUS } from './types'
const ACTIVE_RUN_STATUSES = [
RUN_STATUS.QUEUED,
RUN_STATUS.RUNNING,
RUN_STATUS.CANCELING,
] as const
const ACTIVE_STEP_STATUSES = [
RUN_STEP_STATUS.PENDING,
RUN_STEP_STATUS.RUNNING,
] as const
const TERMINAL_TASK_STATUSES = [
TASK_STATUS.COMPLETED,
TASK_STATUS.FAILED,
TASK_STATUS.CANCELED,
TASK_STATUS.DISMISSED,
] as const
type ActiveRunRow = {
id: string
status: string
taskId: string | null
updatedAt: Date
leaseExpiresAt: Date | null
heartbeatAt: Date | null
cancelRequestedAt: Date | null
}
type LinkedTaskRow = {
id: string
status: string
result: unknown
errorCode: string | null
errorMessage: string | null
finishedAt: Date | null
}
export type ReconciledActiveRun = {
runId: string
taskId: string | null
nextStatus: 'completed' | 'failed'
reason: string
}
const LEASE_EXPIRED_RECONCILE_GRACE_MS = 30_000
const CANCELING_RUN_TIMEOUT_MS = 5 * 60_000
function toObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function toNullableJson(value: Record<string, unknown> | null) {
if (value === null) return Prisma.JsonNull
return value as Prisma.InputJsonValue
}
function buildFailedReason(task: LinkedTaskRow): string {
if (task.status === TASK_STATUS.CANCELED) {
return task.errorMessage?.trim() || 'Linked task was canceled'
}
if (task.status === TASK_STATUS.DISMISSED) {
return task.errorMessage?.trim() || 'Linked task was dismissed'
}
return task.errorMessage?.trim() || 'Linked task failed'
}
export async function reconcileActiveRunsFromTasks(limit = 200): Promise<ReconciledActiveRun[]> {
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.floor(limit), 1), 500) : 200
const now = Date.now()
const activeRuns = await prisma.graphRun.findMany({
where: {
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
orderBy: {
updatedAt: 'asc',
},
take: safeLimit,
select: {
id: true,
status: true,
taskId: true,
updatedAt: true,
leaseExpiresAt: true,
heartbeatAt: true,
cancelRequestedAt: true,
},
}) as ActiveRunRow[]
if (activeRuns.length === 0) return []
const staleRuns = activeRuns.filter((run) => {
if (
run.status === RUN_STATUS.RUNNING
&& run.leaseExpiresAt
&& now - run.leaseExpiresAt.getTime() >= LEASE_EXPIRED_RECONCILE_GRACE_MS
) {
return !run.heartbeatAt || run.heartbeatAt.getTime() <= run.leaseExpiresAt.getTime()
}
if (
run.status === RUN_STATUS.CANCELING
&& run.cancelRequestedAt
&& now - run.cancelRequestedAt.getTime() >= CANCELING_RUN_TIMEOUT_MS
) {
return true
}
return false
})
const reconciled: ReconciledActiveRun[] = []
for (const run of staleRuns) {
const failureMessage = run.status === RUN_STATUS.CANCELING
? 'Run cancel request timed out'
: 'Run lease expired without heartbeat'
const failureCode = run.status === RUN_STATUS.CANCELING ? 'RUN_CANCEL_TIMEOUT' : 'RUN_LEASE_EXPIRED'
const settled = await prisma.$transaction(async (tx) => {
const updatedRun = await tx.graphRun.updateMany({
where: {
id: run.id,
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
data: {
status: RUN_STATUS.FAILED,
errorCode: failureCode,
errorMessage: failureMessage,
finishedAt: new Date(),
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
},
})
if (updatedRun.count === 0) return false
await tx.graphStep.updateMany({
where: {
runId: run.id,
status: {
in: [...ACTIVE_STEP_STATUSES],
},
},
data: {
status: RUN_STEP_STATUS.FAILED,
finishedAt: new Date(),
lastErrorCode: failureCode,
lastErrorMessage: failureMessage,
},
})
return true
})
if (settled) {
reconciled.push({
runId: run.id,
taskId: run.taskId,
nextStatus: 'failed',
reason: failureCode,
})
}
}
const taskIds = activeRuns
.map((run) => run.taskId)
.filter((taskId): taskId is string => typeof taskId === 'string' && taskId.trim().length > 0)
if (taskIds.length === 0) return reconciled
const tasks = await prisma.task.findMany({
where: {
id: {
in: taskIds,
},
status: {
in: [...TERMINAL_TASK_STATUSES],
},
},
select: {
id: true,
status: true,
result: true,
errorCode: true,
errorMessage: true,
finishedAt: true,
},
}) as LinkedTaskRow[]
if (tasks.length === 0) return reconciled
const taskMap = new Map<string, LinkedTaskRow>(tasks.map((task) => [task.id, task]))
for (const run of activeRuns) {
const taskId = run.taskId?.trim() || ''
if (!taskId) continue
const task = taskMap.get(taskId)
if (!task) continue
const settledAt = task.finishedAt || new Date()
if (task.status === TASK_STATUS.COMPLETED) {
const settled = await prisma.$transaction(async (tx) => {
const updatedRun = await tx.graphRun.updateMany({
where: {
id: run.id,
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
data: {
status: RUN_STATUS.COMPLETED,
output: toNullableJson(toObject(task.result)),
errorCode: null,
errorMessage: null,
finishedAt: settledAt,
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
},
})
if (updatedRun.count === 0) return false
await tx.graphStep.updateMany({
where: {
runId: run.id,
status: {
in: [...ACTIVE_STEP_STATUSES],
},
},
data: {
status: RUN_STEP_STATUS.COMPLETED,
finishedAt: settledAt,
lastErrorCode: null,
lastErrorMessage: null,
},
})
return true
})
if (settled) {
reconciled.push({
runId: run.id,
taskId: run.taskId,
nextStatus: 'completed',
reason: 'linked task already completed',
})
}
continue
}
const failureMessage = buildFailedReason(task)
const settled = await prisma.$transaction(async (tx) => {
const updatedRun = await tx.graphRun.updateMany({
where: {
id: run.id,
status: {
in: [...ACTIVE_RUN_STATUSES],
},
},
data: {
status: RUN_STATUS.FAILED,
errorCode: task.errorCode?.trim() || 'TASK_TERMINATED',
errorMessage: failureMessage,
finishedAt: settledAt,
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
},
})
if (updatedRun.count === 0) return false
await tx.graphStep.updateMany({
where: {
runId: run.id,
status: {
in: [...ACTIVE_STEP_STATUSES],
},
},
data: {
status: RUN_STEP_STATUS.FAILED,
finishedAt: settledAt,
lastErrorCode: task.errorCode?.trim() || 'TASK_TERMINATED',
lastErrorMessage: failureMessage,
},
})
return true
})
if (settled) {
reconciled.push({
runId: run.id,
taskId: run.taskId,
nextStatus: 'failed',
reason: `linked task already ${task.status}`,
})
}
}
return reconciled
}

View File

@@ -0,0 +1,85 @@
import { RUN_STATUS, type RunStatus } from './types'
export type RecoverableRunRecord = {
id?: string | null
status?: string | null
createdAt?: string | null
updatedAt?: string | null
leaseExpiresAt?: string | null
heartbeatAt?: string | null
}
export type RunRecoveryDecision = {
runId: string | null
reason: 'latest_active' | 'expired_lease' | 'terminal' | 'missing'
}
const ACTIVE_RUN_STATUSES = new Set<RunStatus>([
RUN_STATUS.QUEUED,
RUN_STATUS.RUNNING,
RUN_STATUS.CANCELING,
])
function toTimestamp(value: string | null | undefined): number {
if (!value) return 0
const ts = Date.parse(value)
return Number.isFinite(ts) ? ts : 0
}
export function isRecoverableRunRecord(run: RecoverableRunRecord, now = Date.now()) {
const status = typeof run.status === 'string' ? run.status : ''
if (!ACTIVE_RUN_STATUSES.has(status as RunStatus)) {
return false
}
if (status === RUN_STATUS.QUEUED) {
return true
}
const leaseExpiresAt = toTimestamp(run.leaseExpiresAt)
if (!leaseExpiresAt) {
return true
}
if (leaseExpiresAt >= now) {
return true
}
const heartbeatAt = toTimestamp(run.heartbeatAt)
return heartbeatAt > leaseExpiresAt
}
export function selectRecoverableRun(runs: RecoverableRunRecord[], now = Date.now()): RunRecoveryDecision {
if (!Array.isArray(runs) || runs.length === 0) {
return {
runId: null,
reason: 'missing',
}
}
const orderedRuns = [...runs].sort((left, right) => {
const updatedDelta = toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt)
if (updatedDelta !== 0) return updatedDelta
return toTimestamp(right.createdAt) - toTimestamp(left.createdAt)
})
let sawExpiredLease = false
for (const run of orderedRuns) {
const runId = typeof run.id === 'string' ? run.id.trim() : ''
if (!runId) continue
const status = typeof run.status === 'string' ? run.status : ''
if (!ACTIVE_RUN_STATUSES.has(status as RunStatus)) {
continue
}
if (isRecoverableRunRecord(run, now)) {
return {
runId,
reason: 'latest_active',
}
}
sawExpiredLease = true
}
return {
runId: null,
reason: sawExpiredLease ? 'expired_lease' : 'terminal',
}
}

View File

@@ -1,4 +1,5 @@
import { prisma } from '@/lib/prisma'
import { selectRecoverableRun } from '@/lib/run-runtime/recovery'
import { resolveRetryInvalidationStepKeys } from '@/lib/workflow-engine/dependencies'
import {
RUN_EVENT_TYPE,
@@ -245,6 +246,24 @@ function mapRunRow(run: GraphRunRow) {
}
}
function mapRunRowToRecoverableRecord(run: GraphRunRow) {
return {
id: run.id,
status: run.status,
createdAt: run.createdAt.toISOString(),
updatedAt: run.updatedAt.toISOString(),
leaseExpiresAt: toIso(run.leaseExpiresAt),
heartbeatAt: toIso(run.heartbeatAt),
}
}
function filterRecoverableRunRows(rows: GraphRunRow[]): GraphRunRow[] {
if (rows.length === 0) return []
const recoverableRunId = selectRecoverableRun(rows.map(mapRunRowToRecoverableRecord)).runId
if (!recoverableRunId) return []
return rows.filter((row) => row.id === recoverableRunId)
}
function mapStepRow(step: GraphStepRow) {
return {
id: step.id,
@@ -718,9 +737,9 @@ export async function findReusableActiveRun(params: {
{ updatedAt: 'desc' },
{ createdAt: 'desc' },
],
take: 1,
take: 20,
})
const row = rows[0]
const row = filterRecoverableRunRows(rows)[0] || null
return row ? mapRunRow(row) : null
}
@@ -839,10 +858,19 @@ export async function listRuns(input: ListRunsInput) {
...(input.episodeId ? { episodeId: input.episodeId } : {}),
...(statusFilter ? { status: statusFilter } : {}),
},
orderBy: { createdAt: 'desc' },
orderBy: [
{ updatedAt: 'desc' },
{ createdAt: 'desc' },
],
take: safeLimit,
})
return rows.map(mapRunRow)
const filteredRows = input.recoverableOnly
? filterRecoverableRunRows(rows)
: rows
const latestRows = input.latestOnly
? filteredRows.slice(0, 1)
: filteredRows
return latestRows.map(mapRunRow)
}
export async function requestRunCancel(params: {

View File

@@ -85,6 +85,8 @@ export type ListRunsInput = {
episodeId?: string
statuses?: RunStatus[]
limit?: number
recoverableOnly?: boolean
latestOnly?: boolean
}
export type StateRef = {

View File

@@ -173,7 +173,11 @@ export function getFilteredFullDescription(characters: CharacterAsset[], clipCha
}
// 根据 clip.location 筛选场景描述
export function getFilteredLocationsDescription(locations: LocationAsset[], clipLocation: string | null): string {
export function getFilteredLocationsDescription(
locations: LocationAsset[],
clipLocation: string | null,
locale: Locale = 'zh',
): string {
return compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations,
@@ -181,6 +185,7 @@ export function getFilteredLocationsDescription(locations: LocationAsset[], clip
clipCharacters: [],
clipLocation,
clipProps: [],
locale,
})).locationDescriptionText
}
@@ -405,7 +410,11 @@ export async function executePhase2(
const clipProps = parseClipProps(clip.props)
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations,
clipLocation,
locale,
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],
@@ -588,7 +597,11 @@ export async function executePhase3(
const clipProps = parseClipProps(clip.props)
const filteredFullDescription = getFilteredFullDescription(novelPromotionData.characters, clipCharacters)
const filteredLocationsDescription = getFilteredLocationsDescription(novelPromotionData.locations, clipLocation)
const filteredLocationsDescription = getFilteredLocationsDescription(
novelPromotionData.locations,
clipLocation,
locale,
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],
locations: [],

View File

@@ -255,12 +255,14 @@ export function startTaskWatchdog() {
// 2. 对账 DB vs BullMQ
const reconciled = await reconcileActiveTasks()
const { reconcileActiveRunsFromTasks } = await import('@/lib/run-runtime/reconcile')
const reconciledRuns = await reconcileActiveRunsFromTasks()
const total = sweptProcessing.length + reconciled.length
const total = sweptProcessing.length + reconciled.length + reconciledRuns.length
if (total > 0) {
logger.info({
action: 'watchdog.cycle',
message: `Watchdog: ${sweptProcessing.length} heartbeat-timeout, ${reconciled.length} orphan-reconciled`,
message: `Watchdog: ${sweptProcessing.length} heartbeat-timeout, ${reconciled.length} orphan-reconciled, ${reconciledRuns.length} run-reconciled`,
})
}
} catch (error) {

View File

@@ -48,6 +48,14 @@ function resolveRunIdFromPayload(payload: unknown): string | null {
return runIdFromMeta || null
}
export function isActiveTaskStatus(status: string | null | undefined) {
return status === TASK_STATUS.QUEUED || status === TASK_STATUS.PROCESSING
}
export function shouldAttachNewTaskToReusableRun(reusableRunTaskStatus: string | null | undefined) {
return !isActiveTaskStatus(reusableRunTaskStatus)
}
export function normalizeTaskPayload(type: TaskType, payload?: Record<string, unknown> | null) {
const nextPayload = {
...(payload || {}),
@@ -146,22 +154,19 @@ export async function submitTask(params: {
targetId: params.targetId,
})
: null
const reusableRunTask = reusableRun?.taskId
? await getTaskById(reusableRun.taskId)
: null
if (runCentricTask && reusableRun?.taskId) {
const existingTask = await getTaskById(reusableRun.taskId)
if (
existingTask
&& (existingTask.status === TASK_STATUS.QUEUED || existingTask.status === TASK_STATUS.PROCESSING)
) {
if (runCentricTask && reusableRun && reusableRunTask && isActiveTaskStatus(reusableRunTask.status)) {
return {
success: true,
async: true,
taskId: existingTask.id,
taskId: reusableRunTask.id,
runId: reusableRun.id,
status: existingTask.status,
status: reusableRunTask.status,
deduped: true as const,
}
}
}
const { task, deduped } = await createTask({
@@ -177,8 +182,11 @@ export async function submitTask(params: {
maxAttempts: params.maxAttempts,
billingInfo: resolvedBillingInfo || null,
})
let runId = reusableRun?.id || resolveRunIdFromPayload(task.payload)
if (!deduped && reusableRun && runId) {
const reusableRunId = reusableRun && shouldAttachNewTaskToReusableRun(reusableRunTask?.status)
? (reusableRun?.id || null)
: null
let runId = reusableRunId || resolveRunIdFromPayload(task.payload)
if (!deduped && reusableRunId && runId) {
const payloadWithRunId = {
...normalizedPayload,
runId,

View File

@@ -10,6 +10,7 @@ import {
type CharacterBrief,
} from './analyze-global-parse'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
export type AnalyzeGlobalStats = {
totalChunks: number
@@ -166,6 +167,7 @@ export async function persistAnalyzeGlobalChunk(params: {
: (readText(loc.description) ? [readText(loc.description)] : [])
const descriptions = descriptionsRaw.map((item) => readText(item)).filter(Boolean)
const cleanDescriptions = descriptions.map((item) => removeLocationPromptSuffix(item))
const availableSlots = normalizeLocationAvailableSlots(loc.available_slots)
const created = await prisma.novelPromotionLocation.create({
data: {
@@ -182,6 +184,7 @@ export async function persistAnalyzeGlobalChunk(params: {
locationId: created.id,
descriptions: cleanDescriptions,
fallbackDescription: summary || name,
availableSlots,
})
params.existingLocationNames.push(name)
@@ -219,6 +222,7 @@ export async function persistAnalyzeGlobalChunk(params: {
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
availableSlots: [],
})
params.existingPropNames.push(name)
params.stats.newProps += 1

View File

@@ -11,6 +11,7 @@ import type { TaskJobData } from '@/lib/task/types'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import { resolveAnalysisModel } from './resolve-analysis-model'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
function readAssetKind(value: Record<string, unknown>): string {
return typeof value.assetKind === 'string' ? value.assetKind : 'location'
@@ -316,10 +317,12 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
})
const cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || ''))
const availableSlots = normalizeLocationAvailableSlots(item.available_slots)
await seedProjectLocationBackedImageSlots({
locationId: created.id,
descriptions: cleanDescriptions,
fallbackDescription: readText(item.summary) || name,
availableSlots,
})
createdLocations.push(created)
@@ -352,6 +355,7 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
availableSlots: [],
})
existingPropNameSet.add(normalizedName)
createdProps.push(created)

View File

@@ -68,5 +68,6 @@ export async function handleAssetHubAIDesignTask(job: Job<TaskJobData>) {
return {
prompt: result.prompt,
availableSlots: result.availableSlots ?? [],
}
}

View File

@@ -9,6 +9,7 @@ import type { TaskJobData } from '@/lib/task/types'
import { TASK_TYPE } from '@/lib/task/types'
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
function readRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim()) {
@@ -19,13 +20,19 @@ function readRequiredString(value: unknown, field: string): string {
import { safeParseJsonObject } from '@/lib/json-repair'
function parseJsonPrompt(responseText: string): string {
function parseJsonPrompt(responseText: string): {
prompt: string
availableSlots: ReturnType<typeof normalizeLocationAvailableSlots>
} {
const parsed = safeParseJsonObject(responseText)
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt.trim() : ''
if (!prompt) {
throw new Error('No prompt field in response')
}
return prompt
return {
prompt,
availableSlots: normalizeLocationAvailableSlots(parsed.available_slots),
}
}
export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
@@ -96,7 +103,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
await streamCallbacks.flush()
await assertTaskActive(job, 'asset_hub_ai_modify_parse')
const modifiedDescription = parseJsonPrompt(completion.text)
const parsed = parseJsonPrompt(completion.text)
await reportTaskProgress(job, 96, {
stage: 'asset_hub_ai_modify_done',
@@ -110,6 +117,7 @@ export async function handleAssetHubAIModifyTask(job: Job<TaskJobData>) {
return {
success: true,
modifiedDescription,
modifiedDescription: parsed.prompt,
availableSlots: parsed.availableSlots,
}
}

View File

@@ -5,6 +5,7 @@ import { type TaskJobData } from '@/lib/task/types'
import { encodeImageUrls } from '@/lib/contracts/image-urls-contract'
import { normalizeImageGenerationCount } from '@/lib/image-generation/count'
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
import {
assertTaskActive,
getUserModels,
@@ -32,6 +33,7 @@ interface GlobalCharacterRecord {
interface GlobalLocationImageRecord {
id: string
description: string | null
availableSlots?: string | null
}
interface GlobalLocationRecord {
@@ -140,7 +142,12 @@ export async function handleAssetHubImageTask(job: Job<TaskJobData>) {
for (const image of targetImages) {
if (!image.description) continue
const prompt = artStyle ? `${addLocationPromptSuffix(image.description)}${artStyle}` : addLocationPromptSuffix(image.description)
const promptCore = buildLocationImagePromptCore({
description: image.description,
availableSlotsRaw: image.availableSlots,
locale: job.data.locale === 'en' ? 'en' : 'zh',
})
const prompt = artStyle ? `${addLocationPromptSuffix(promptCore)}${artStyle}` : addLocationPromptSuffix(promptCore)
const cosKey = await generateLabeledImageToCos({
job,

View File

@@ -13,6 +13,10 @@ import {
import {
normalizeReferenceImagesForGeneration,
} from '@/lib/media/outbound-image'
import {
type LocationAvailableSlot,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
import {
AnyObj,
parseImageUrls,
@@ -51,6 +55,7 @@ interface GlobalLocationImageRecord {
id: string
imageIndex: number
description: string | null
availableSlots?: string | null
imageUrl: string | null
previousDescription: string | null
}
@@ -166,7 +171,7 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
descriptions: appearance.descriptions,
fallbackDescription: appearance.description,
index: targetImageIndex,
nextDescription,
nextDescription: nextDescription.prompt,
})
} catch (err) {
logger.warn({ message: '资产库角色描述同步失败', details: { error: String(err) } })
@@ -231,7 +236,10 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
const labeled = await withLabelBar(source, location.name)
const cosKey = await uploadImageSourceToCos(labeled, 'global-location-modify', locationImage.id)
let extractedDescription: string | undefined
let extractedDescription: {
prompt: string
availableSlots: LocationAvailableSlot[]
} | null = null
if (locationImage.description && modifyInstruction && userModels.analysisModel) {
try {
extractedDescription = await generateModifiedAssetDescription({
@@ -256,7 +264,10 @@ export async function handleAssetHubModifyTask(job: Job<TaskJobData>) {
previousImageUrl: locationImage.imageUrl,
previousDescription: locationImage.description || null,
imageUrl: cosKey,
...(extractedDescription ? { description: extractedDescription } : {}),
...(extractedDescription ? {
description: extractedDescription.prompt,
availableSlots: stringifyLocationAvailableSlots(extractedDescription.availableSlots),
} : {}),
},
})

View File

@@ -28,6 +28,7 @@ interface CharacterLike {
interface LocationImageLike {
description?: string | null
availableSlots?: string | null
imageIndex?: number
isSelected: boolean
imageUrl: string | null
@@ -53,6 +54,7 @@ interface PanelLike {
export interface PanelCharacterReference {
name: string
appearance?: string
slot?: string
}
interface NovelDataDb {
@@ -145,11 +147,12 @@ export function parsePanelCharacterReferences(value: string | null | undefined):
.map((item: unknown) => {
if (typeof item === 'string') return { name: item }
if (!item || typeof item !== 'object') return null
const candidate = item as { name?: unknown; appearance?: unknown }
const candidate = item as { name?: unknown; appearance?: unknown; slot?: unknown }
if (typeof candidate.name === 'string') {
return {
name: candidate.name,
appearance: typeof candidate.appearance === 'string' ? candidate.appearance : undefined,
slot: typeof candidate.slot === 'string' ? candidate.slot : undefined,
}
}
return null

View File

@@ -16,6 +16,10 @@ import {
normalizeReferenceImagesForGeneration,
normalizeToBase64ForGeneration,
} from '@/lib/media/outbound-image'
import {
type LocationAvailableSlot,
stringifyLocationAvailableSlots,
} from '@/lib/location-available-slots'
import {
AnyObj,
parseImageUrls,
@@ -35,6 +39,7 @@ interface LocationImageRecord {
id: string
locationId: string
description: string | null
availableSlots?: string | null
imageUrl: string | null
previousDescription: string | null
location: {
@@ -138,7 +143,7 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
descriptions: appearance.descriptions,
fallbackDescription: appearance.description,
index: imageIndex,
nextDescription,
nextDescription: nextDescription.prompt,
})
}
} catch (err) {
@@ -215,7 +220,10 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
const labeled = await withLabelBar(source, label)
const cosKey = await uploadImageSourceToCos(labeled, 'location-modify', locationImage.id)
let extractedDescription: string | undefined
let extractedDescription: {
prompt: string
availableSlots: LocationAvailableSlot[]
} | null = null
if (locationImage.description && modifyInstruction) {
try {
const userModels = await getUserModels(job.data.userId)
@@ -245,7 +253,10 @@ export async function handleModifyAssetImageTask(job: Job<TaskJobData>) {
previousImageUrl: locationImage.imageUrl,
previousDescription: locationImage.description || null,
imageUrl: cosKey,
...(extractedDescription ? { description: extractedDescription } : {}),
...(extractedDescription ? {
description: extractedDescription.prompt,
availableSlots: stringifyLocationAvailableSlots(extractedDescription.availableSlots),
} : {}),
},
})

View File

@@ -13,6 +13,7 @@ import {
generateLabeledImageToCos,
pickFirstString,
} from './image-task-handler-shared'
import { buildLocationImagePromptCore } from '@/lib/location-image-prompt'
function resolvePayloadArtStyle(payload: AnyObj): ArtStyleValue | undefined {
if (!Object.prototype.hasOwnProperty.call(payload, 'artStyle')) return undefined
@@ -27,6 +28,7 @@ interface LocationImageRecord {
id: string
locationId: string
description: string | null
availableSlots?: string | null
imageIndex: number
location?: { name: string } | null
}
@@ -139,8 +141,13 @@ export async function handleLocationImageTask(job: Job<TaskJobData>) {
const name = locationNameMap[item.locationId] || item.location?.name || '场景'
const promptBody = item.description || ''
if (!promptBody) continue
const promptCore = buildLocationImagePromptCore({
description: promptBody,
availableSlotsRaw: item.availableSlots,
locale: job.data.locale === 'en' ? 'en' : 'zh',
})
const prompt = artStyle ? `${addLocationPromptSuffix(promptBody)}${artStyle}` : addLocationPromptSuffix(promptBody)
const prompt = artStyle ? `${addLocationPromptSuffix(promptCore)}${artStyle}` : addLocationPromptSuffix(promptCore)
await reportTaskProgress(job, 20 + Math.floor((i / Math.max(locationImages.length, 1)) * 55), {
stage: 'generate_location_image',
imageId: item.id,

View File

@@ -7,6 +7,10 @@ import {
buildCharacterDescriptionFields,
readIndexedDescription,
} from '@/lib/assets/description-fields'
import {
type LocationAvailableSlot,
normalizeLocationAvailableSlots,
} from '@/lib/location-available-slots'
export type SyncedAssetType = 'character' | 'location'
@@ -22,13 +26,19 @@ function buildImageContext(type: SyncedAssetType, hasReferenceImages: boolean):
return '【参考图片】\n请仔细分析参考图片中的建筑风格、装饰元素、光线氛围、色调等关键视觉特征并将这些特征融入更新后的描述中。'
}
function parseModifiedDescription(responseText: string): string {
function parseModifiedDescription(responseText: string): {
prompt: string
availableSlots: LocationAvailableSlot[]
} {
const parsed = safeParseJsonObject(responseText)
const prompt = trimText(typeof parsed.prompt === 'string' ? parsed.prompt : '')
if (!prompt) {
throw new Error('No prompt field in response')
}
return prompt
return {
prompt,
availableSlots: normalizeLocationAvailableSlots(parsed.available_slots),
}
}
export { buildCharacterDescriptionFields, readIndexedDescription }
@@ -43,7 +53,10 @@ export async function generateModifiedAssetDescription(params: {
referenceImages?: string[]
locationName?: string
projectId?: string
}): Promise<string> {
}): Promise<{
prompt: string
availableSlots: LocationAvailableSlot[]
}> {
const hasReferenceImages = Array.isArray(params.referenceImages) && params.referenceImages.length > 0
const finalPrompt = params.type === 'character'
? buildPrompt({

View File

@@ -21,6 +21,9 @@ import {
resolveNovelData,
} from './image-task-handler-shared'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import {
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
function parseJsonUnknown(raw: string | null | undefined): unknown | null {
if (!raw) return null
@@ -65,6 +68,7 @@ function buildPanelPromptContext(params: {
shotType: string | null
cameraMove: string | null
description: string | null
imagePrompt: string | null
videoPrompt: string | null
location: string | null
characters: string | null
@@ -95,6 +99,7 @@ function buildPanelPromptContext(params: {
name: character.name,
appearance: matchedAppearance?.changeReason || null,
description: matchedAppearance ? pickAppearanceDescription(matchedAppearance) : '无角色外貌数据',
slot: reference.slot || null,
}
})
@@ -108,6 +113,7 @@ function buildPanelPromptContext(params: {
return {
name: matchedLocation.name,
description: selectedImage?.description || null,
available_slots: parseLocationAvailableSlots(selectedImage?.availableSlots),
}
})()
@@ -117,6 +123,7 @@ function buildPanelPromptContext(params: {
shot_type: params.panel.shotType || '',
camera_move: params.panel.cameraMove || '',
description: params.panel.description || '',
image_prompt: params.panel.imagePrompt || '',
video_prompt: params.panel.videoPrompt || '',
location: params.panel.location || '',
characters: panelCharacters,
@@ -203,6 +210,7 @@ export async function handlePanelImageTask(job: Job<TaskJobData>) {
shotType: panel.shotType,
cameraMove: panel.cameraMove,
description: panel.description,
imagePrompt: panel.imagePrompt,
videoPrompt: panel.videoPrompt,
location: panel.location,
characters: panel.characters,

View File

@@ -11,6 +11,10 @@ import {
uploadImageSourceToCos,
} from '../utils'
import { normalizeReferenceImagesForGeneration } from '@/lib/media/outbound-image'
import {
formatLocationAvailableSlotsText,
parseLocationAvailableSlots,
} from '@/lib/location-available-slots'
import {
AnyObj,
findCharacterByName,
@@ -75,7 +79,8 @@ function buildCharactersInfo(
const character = findCharacterByName(projectData.characters || [], item.name)
const intro = character?.introduction || ''
const appearance = item.appearance || '默认形象'
return `- ${item.name}${appearance}${intro ? `${intro}` : ''}`
const slotText = item.slot ? `,固定位置${item.slot}` : ''
return `- ${item.name}${appearance}${slotText}${intro ? `${intro}` : ''}`
}).join('\n')
}
@@ -98,9 +103,28 @@ function buildLocationAssetDescription(params: {
includeLocationAsset: boolean
locationName: string
locale: TaskJobData['locale']
projectData: Awaited<ReturnType<typeof resolveNovelData>>
}): string {
if (params.locationName) {
if (params.includeLocationAsset) return `场景:${params.locationName}`
if (params.includeLocationAsset) {
const location = (params.projectData.locations || []).find(
(item) => item.name.toLowerCase() === params.locationName.toLowerCase(),
)
const selectedImage = location?.images?.find((image) => image.isSelected) ?? location?.images?.[0]
const description = selectedImage?.description?.trim()
const slotsText = formatLocationAvailableSlotsText(
parseLocationAvailableSlots(selectedImage?.availableSlots),
params.locale,
)
const parts = [
params.locale === 'en' ? `Location: ${params.locationName}` : `场景:${params.locationName}`,
]
if (description) {
parts.push(params.locale === 'en' ? `Scene description: ${description}` : `场景描述:${description}`)
}
if (slotsText) parts.push(slotsText)
return parts.join('\n')
}
return params.locale === 'en' ? 'Location reference disabled' : '未使用场景参考图'
}
return params.locale === 'en' ? 'No location reference' : '无场景参考'
@@ -235,6 +259,7 @@ export async function handlePanelVariantTask(job: Job<TaskJobData>) {
includeLocationAsset,
locationName,
locale: job.data.locale,
projectData,
}),
aspectRatio,
style: artStyle || '与参考图风格一致',

View File

@@ -334,6 +334,7 @@ export async function runScriptToStoryboardAtomicRetry(params: {
runId: string
retryTarget: StoryboardRetryTarget
retryStepAttempt: number
locale?: 'zh' | 'en'
clip: StoryboardClipInput
clipIndex: number
totalClipCount: number
@@ -352,6 +353,7 @@ export async function runScriptToStoryboardAtomicRetry(params: {
const filteredLocationsDescription = getFilteredLocationsDescription(
params.novelPromotionData.locations || [],
clipLocation,
params.locale ?? 'zh',
)
const filteredPropsDescription = compileAssetPromptFragments(buildPromptAssetContext({
characters: [],

View File

@@ -104,6 +104,34 @@ export function buildStoryboardJson(storyboards: PersistedStoryboard[]) {
return JSON.stringify(rows, null, 2)
}
export function buildStoryboardJsonFromClipPanels(clipPanels: ClipPanelsResult[]) {
const rows: Array<{
storyboardId: string
panelIndex: number
text_segment: string
description: string
characters: string[]
props: string[]
}> = []
for (const clipEntry of clipPanels) {
for (let index = 0; index < clipEntry.finalPanels.length; index += 1) {
const panel = clipEntry.finalPanels[index]
rows.push({
storyboardId: clipEntry.clipId,
panelIndex: index,
text_segment: panel.source_text || '',
description: panel.description || '',
characters: Array.isArray(panel.characters) ? panel.characters.filter(Boolean) : [],
props: Array.isArray(panel.props) ? panel.props.filter(Boolean) : [],
})
}
}
if (rows.length === 0) return '无分镜数据'
return JSON.stringify(rows, null, 2)
}
export async function persistStoryboardsAndPanels(params: {
episodeId: string
clipPanels: ClipPanelsResult[]
@@ -193,3 +221,221 @@ export async function persistStoryboardsAndPanels(params: {
return persisted
}, { timeout: 30000 })
}
export async function persistStoryboardOutputs(params: {
episodeId: string
clipPanels: ClipPanelsResult[]
voiceLineRows: JsonRecord[] | null
}) {
const persistedStoryboards = await prisma.$transaction(async (tx) => {
const persisted: PersistedStoryboard[] = []
const panelIdByStoryboardRef = new Map<string, string>()
const storyboardIdByRef = new Map<string, string>()
for (const clipEntry of params.clipPanels) {
const storyboard = await tx.novelPromotionStoryboard.upsert({
where: { clipId: clipEntry.clipId },
create: {
clipId: clipEntry.clipId,
episodeId: params.episodeId,
panelCount: clipEntry.finalPanels.length,
},
update: {
panelCount: clipEntry.finalPanels.length,
episodeId: params.episodeId,
lastError: null,
},
select: { id: true, clipId: true },
})
storyboardIdByRef.set(storyboard.id, storyboard.id)
storyboardIdByRef.set(clipEntry.clipId, storyboard.id)
await tx.novelPromotionPanel.deleteMany({
where: { storyboardId: storyboard.id },
})
const panelModel = tx.novelPromotionPanel as unknown as {
create: (args: {
data: Record<string, unknown>
select: {
id: true
panelIndex: true
description: true
srtSegment: true
characters: true
props: true
}
}) => Promise<{
id: string
panelIndex: number
description: string | null
srtSegment: string | null
characters: string | null
props: string | null
}>
}
const persistedPanels: PersistedStoryboard['panels'] = []
for (let i = 0; i < clipEntry.finalPanels.length; i += 1) {
const panel = clipEntry.finalPanels[i]
const created = await panelModel.create({
data: {
storyboardId: storyboard.id,
panelIndex: i,
panelNumber: panel.panel_number || i + 1,
shotType: panel.shot_type || '中景',
cameraMove: panel.camera_move || '固定',
description: panel.description || null,
videoPrompt: panel.video_prompt || null,
location: panel.location || null,
characters: panel.characters ? JSON.stringify(panel.characters) : null,
props: panel.props ? JSON.stringify(panel.props) : null,
srtSegment: panel.source_text || null,
photographyRules: panel.photographyPlan ? JSON.stringify(panel.photographyPlan) : null,
actingNotes: panel.actingNotes ? JSON.stringify(panel.actingNotes) : null,
duration: panel.duration || null,
},
select: {
id: true,
panelIndex: true,
description: true,
srtSegment: true,
characters: true,
props: true,
},
})
panelIdByStoryboardRef.set(`${storyboard.id}:${created.panelIndex}`, created.id)
panelIdByStoryboardRef.set(`${clipEntry.clipId}:${created.panelIndex}`, created.id)
persistedPanels.push(created)
}
persisted.push({
storyboardId: storyboard.id,
clipId: storyboard.clipId,
panels: persistedPanels,
})
}
const voiceLineModel = tx.novelPromotionVoiceLine as unknown as {
upsert?: (args: unknown) => Promise<{ id: string }>
create: (args: unknown) => Promise<{ id: string }>
deleteMany: (args: unknown) => Promise<unknown>
}
const createdVoiceLines: Array<{ id: string }> = []
const voiceLineRows = params.voiceLineRows ?? []
for (let i = 0; i < voiceLineRows.length; i += 1) {
const row = voiceLineRows[i] || {}
const matchedPanel = asJsonRecord(row.matchedPanel)
const matchedStoryboardRef =
matchedPanel && typeof matchedPanel.storyboardId === 'string'
? matchedPanel.storyboardId.trim()
: null
const matchedPanelIndex = matchedPanel ? toPositiveInt(matchedPanel.panelIndex) : null
let matchedPanelId: string | null = null
let matchedStoryboardId: string | null = null
if (matchedPanel !== null) {
if (!matchedStoryboardRef || matchedPanelIndex === null) {
throw new Error(`voice line ${i + 1} has invalid matchedPanel reference`)
}
matchedStoryboardId = storyboardIdByRef.get(matchedStoryboardRef) || null
if (!matchedStoryboardId) {
throw new Error(`voice line ${i + 1} references non-existent storyboard ${matchedStoryboardRef}`)
}
const panelKey = `${matchedStoryboardRef}:${matchedPanelIndex}`
const resolvedPanelId = panelIdByStoryboardRef.get(panelKey)
if (!resolvedPanelId) {
throw new Error(`voice line ${i + 1} references non-existent panel ${panelKey}`)
}
matchedPanelId = resolvedPanelId
}
if (typeof row.emotionStrength !== 'number' || !Number.isFinite(row.emotionStrength)) {
throw new Error(`voice line ${i + 1} is missing valid emotionStrength`)
}
const emotionStrength = Math.min(1, Math.max(0.1, row.emotionStrength))
if (typeof row.lineIndex !== 'number' || !Number.isFinite(row.lineIndex)) {
throw new Error(`voice line ${i + 1} is missing valid lineIndex`)
}
const lineIndex = Math.floor(row.lineIndex)
if (lineIndex <= 0) {
throw new Error(`voice line ${i + 1} has invalid lineIndex`)
}
if (typeof row.speaker !== 'string' || !row.speaker.trim()) {
throw new Error(`voice line ${i + 1} is missing valid speaker`)
}
if (typeof row.content !== 'string' || !row.content.trim()) {
throw new Error(`voice line ${i + 1} is missing valid content`)
}
const upsertArgs = {
where: {
episodeId_lineIndex: {
episodeId: params.episodeId,
lineIndex,
},
},
create: {
episodeId: params.episodeId,
lineIndex,
speaker: row.speaker.trim(),
content: row.content,
emotionStrength,
matchedPanelId,
matchedStoryboardId,
matchedPanelIndex,
},
update: {
speaker: row.speaker.trim(),
content: row.content,
emotionStrength,
matchedPanelId,
matchedStoryboardId,
matchedPanelIndex,
},
select: { id: true },
}
const createdRow = typeof voiceLineModel.upsert === 'function'
? await voiceLineModel.upsert(upsertArgs)
: (
process.env.NODE_ENV === 'test'
? await voiceLineModel.create({
data: upsertArgs.create,
select: { id: true },
})
: (() => { throw new Error('novelPromotionVoiceLine.upsert unavailable') })()
)
createdVoiceLines.push(createdRow)
}
const nextLineIndexes = voiceLineRows
.map((row) => (typeof row.lineIndex === 'number' && Number.isFinite(row.lineIndex) ? Math.floor(row.lineIndex) : -1))
.filter((value) => value > 0)
if (nextLineIndexes.length === 0) {
await voiceLineModel.deleteMany({
where: {
episodeId: params.episodeId,
},
})
} else {
await voiceLineModel.deleteMany({
where: {
episodeId: params.episodeId,
lineIndex: {
notIn: nextLineIndexes,
},
},
})
}
return {
persistedStoryboards: persisted,
createdVoiceLines,
}
}, { timeout: 30000 })
return {
persistedStoryboards: persistedStoryboards.persistedStoryboards,
voiceLineCount: persistedStoryboards.createdVoiceLines.length,
}
}

View File

@@ -21,13 +21,11 @@ import {
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './llm-stream'
import type { TaskJobData } from '@/lib/task/types'
import {
asJsonRecord,
buildStoryboardJson,
buildStoryboardJsonFromClipPanels,
parseEffort,
parseTemperature,
parseVoiceLinesJson,
persistStoryboardsAndPanels,
toPositiveInt,
persistStoryboardOutputs,
type JsonRecord,
} from './script-to-storyboard-helpers'
import { buildPrompt, getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'
@@ -279,6 +277,7 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
runId,
retryTarget,
retryStepAttempt,
locale: job.data.locale,
clip: {
id: clip.id,
content: clip.content,
@@ -321,6 +320,7 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
try {
return await runScriptToStoryboardOrchestrator({
concurrency: workflowConcurrency.analysis,
locale: job.data.locale,
clips: selectedClips.map((clip) => ({
id: clip.id,
content: clip.content,
@@ -428,12 +428,12 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
})
await assertRunActive('script_to_storyboard_persist')
const persistedStoryboards = await persistStoryboardsAndPanels({
episodeId,
clipPanels: orchestratorResult.clipPanels,
})
if (skipVoiceAnalyze) {
const persisted = await persistStoryboardOutputs({
episodeId,
clipPanels: orchestratorResult.clipPanels,
voiceLineRows: null,
})
await reportTaskProgress(job, 96, {
stage: 'script_to_storyboard_persist_done',
stageLabel: 'progress.stage.scriptToStoryboardPersistDone',
@@ -447,7 +447,7 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
})
return {
episodeId,
storyboardCount: persistedStoryboards.length,
storyboardCount: persisted.persistedStoryboards.length,
panelCount: orchestratorResult.summary.totalPanelCount,
voiceLineCount: 0,
retryStepKey,
@@ -467,7 +467,7 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
? (novelData.characters || []).map((item) => item.name).join('、')
: '无',
characters_introduction: buildCharactersIntroduction(novelData.characters || []),
storyboard_json: buildStoryboardJson(persistedStoryboards),
storyboard_json: buildStoryboardJsonFromClipPanels(orchestratorResult.clipPanels),
},
})
@@ -531,122 +531,11 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
})
await assertRunActive('script_to_storyboard_voice_persist')
const panelIdByStoryboardPanel = new Map<string, string>()
for (const storyboard of persistedStoryboards) {
for (const panel of storyboard.panels) {
panelIdByStoryboardPanel.set(`${storyboard.storyboardId}:${panel.panelIndex}`, panel.id)
}
}
const createdVoiceLines = await prisma.$transaction(async (tx) => {
const voiceLineModel = tx.novelPromotionVoiceLine as unknown as {
upsert?: (args: unknown) => Promise<{ id: string }>
create: (args: unknown) => Promise<{ id: string }>
deleteMany: (args: unknown) => Promise<unknown>
}
const created: Array<{ id: string }> = []
for (let i = 0; i < voiceLineRows.length; i += 1) {
const row = voiceLineRows[i] || {}
const matchedPanel = asJsonRecord(row.matchedPanel)
const matchedStoryboardId =
matchedPanel && typeof matchedPanel.storyboardId === 'string'
? matchedPanel.storyboardId.trim()
: null
const matchedPanelIndex = matchedPanel ? toPositiveInt(matchedPanel.panelIndex) : null
let matchedPanelId: string | null = null
if (matchedPanel !== null) {
if (!matchedStoryboardId || matchedPanelIndex === null) {
throw new Error(`voice line ${i + 1} has invalid matchedPanel reference`)
}
const panelKey = `${matchedStoryboardId}:${matchedPanelIndex}`
const resolvedPanelId = panelIdByStoryboardPanel.get(panelKey)
if (!resolvedPanelId) {
throw new Error(`voice line ${i + 1} references non-existent panel ${panelKey}`)
}
matchedPanelId = resolvedPanelId
}
if (typeof row.emotionStrength !== 'number' || !Number.isFinite(row.emotionStrength)) {
throw new Error(`voice line ${i + 1} is missing valid emotionStrength`)
}
const emotionStrength = Math.min(1, Math.max(0.1, row.emotionStrength))
if (typeof row.lineIndex !== 'number' || !Number.isFinite(row.lineIndex)) {
throw new Error(`voice line ${i + 1} is missing valid lineIndex`)
}
const lineIndex = Math.floor(row.lineIndex)
if (lineIndex <= 0) {
throw new Error(`voice line ${i + 1} has invalid lineIndex`)
}
if (typeof row.speaker !== 'string' || !row.speaker.trim()) {
throw new Error(`voice line ${i + 1} is missing valid speaker`)
}
if (typeof row.content !== 'string' || !row.content.trim()) {
throw new Error(`voice line ${i + 1} is missing valid content`)
}
const upsertArgs = {
where: {
episodeId_lineIndex: {
episodeId,
lineIndex,
},
},
create: {
episodeId,
lineIndex,
speaker: row.speaker.trim(),
content: row.content,
emotionStrength,
matchedPanelId,
matchedStoryboardId: matchedPanelId ? matchedStoryboardId : null,
matchedPanelIndex,
},
update: {
speaker: row.speaker.trim(),
content: row.content,
emotionStrength,
matchedPanelId,
matchedStoryboardId: matchedPanelId ? matchedStoryboardId : null,
matchedPanelIndex,
},
select: { id: true },
}
const createdRow = typeof voiceLineModel.upsert === 'function'
? await voiceLineModel.upsert(upsertArgs)
: (
process.env.NODE_ENV === 'test'
? await voiceLineModel.create({
data: upsertArgs.create,
select: { id: true },
})
: (() => { throw new Error('novelPromotionVoiceLine.upsert unavailable') })()
)
created.push(createdRow)
}
const nextLineIndexes = voiceLineRows
.map((row) => (typeof row.lineIndex === 'number' && Number.isFinite(row.lineIndex) ? Math.floor(row.lineIndex) : -1))
.filter((value) => value > 0)
if (nextLineIndexes.length === 0) {
await voiceLineModel.deleteMany({
where: {
episodeId,
},
})
} else {
await voiceLineModel.deleteMany({
where: {
episodeId,
lineIndex: {
notIn: nextLineIndexes,
},
},
})
}
return created
}, { timeout: 15000 })
const persisted = await persistStoryboardOutputs({
episodeId,
clipPanels: orchestratorResult.clipPanels,
voiceLineRows,
})
await reportTaskProgress(job, 96, {
stage: 'script_to_storyboard_persist_done',
@@ -656,9 +545,9 @@ export async function handleScriptToStoryboardTask(job: Job<TaskJobData>) {
return {
episodeId,
storyboardCount: persistedStoryboards.length,
storyboardCount: persisted.persistedStoryboards.length,
panelCount: orchestratorResult.summary.totalPanelCount,
voiceLineCount: createdVoiceLines.length,
voiceLineCount: persisted.voiceLineCount,
}
},
})

View File

@@ -1,5 +1,6 @@
import { prisma } from '@/lib/prisma'
import { composeModelKey, parseModelKeyStrict } from '@/lib/model-config-contract'
import { type LocationAvailableSlot, stringifyLocationAvailableSlots } from '@/lib/location-available-slots'
function normalizeModelKey(value: unknown): string | null {
if (typeof value !== 'string') return null
@@ -54,6 +55,7 @@ export async function persistLocationDescription(params: {
locationId: string
imageIndex: number
modifiedDescription: string
availableSlots?: LocationAvailableSlot[]
}) {
const locationImage = await prisma.locationImage.findFirst({
where: {
@@ -68,7 +70,10 @@ export async function persistLocationDescription(params: {
await prisma.locationImage.update({
where: { id: locationImage.id },
data: { description: params.modifiedDescription },
data: {
description: params.modifiedDescription,
...(params.availableSlots ? { availableSlots: stringifyLocationAvailableSlots(params.availableSlots) } : {}),
},
})
return await prisma.novelPromotionLocation.findUnique({

View File

@@ -1,5 +1,6 @@
import type { Job } from 'bullmq'
import { removeLocationPromptSuffix } from '@/lib/constants'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
import { reportTaskProgress } from '@/lib/workers/shared'
import { assertTaskActive } from '@/lib/workers/utils'
import type { TaskJobData } from '@/lib/task/types'
@@ -52,12 +53,14 @@ export async function handleModifyLocationTask(job: Job<TaskJobData>, payload: A
const parsed = parseJsonObject(responseText)
const prompt = readRequiredString(parsed.prompt, 'prompt')
const modifiedDescription = removeLocationPromptSuffix(prompt)
const availableSlots = normalizeLocationAvailableSlots(parsed.available_slots)
await assertTaskActive(job, 'ai_modify_location_persist')
const updatedLocation = await persistLocationDescription({
locationId,
imageIndex,
modifiedDescription,
availableSlots,
})
await reportTaskProgress(job, 96, {
@@ -71,6 +74,7 @@ export async function handleModifyLocationTask(job: Job<TaskJobData>, payload: A
success: true,
prompt,
modifiedDescription,
availableSlots,
location: updatedLocation,
}
}

View File

@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
import { removeLocationPromptSuffix } from '@/lib/constants'
import type { StoryToScriptClipCandidate } from '@/lib/novel-promotion/story-to-script/orchestrator'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
import { normalizeLocationAvailableSlots } from '@/lib/location-available-slots'
export type AnyObj = Record<string, unknown>
@@ -30,12 +31,27 @@ export function resolveClipRecordId(clipMap: Map<string, string>, clipId: string
return clipMap.get(clipId) || null
}
type CharacterCreateDb = {
novelPromotionCharacter: typeof prisma.novelPromotionCharacter
}
type LocationCreateDb = {
novelPromotionLocation: typeof prisma.novelPromotionLocation
locationImage: typeof prisma.locationImage
}
type ClipPersistDb = {
novelPromotionClip: typeof prisma.novelPromotionClip
}
export async function persistAnalyzedCharacters(params: {
projectInternalId: string
existingNames: Set<string>
analyzedCharacters: Record<string, unknown>[]
db?: CharacterCreateDb
}) {
const created: Array<{ id: string; name: string }> = []
const db = params.db ?? prisma
for (const item of params.analyzedCharacters) {
const name = asString(item.name).trim()
@@ -58,7 +74,7 @@ export async function persistAnalyzedCharacters(params: {
age_range: item.age_range,
}
const createdRow = await prisma.novelPromotionCharacter.create({
const createdRow = await db.novelPromotionCharacter.create({
data: {
novelPromotionProjectId: params.projectInternalId,
name,
@@ -84,9 +100,11 @@ export async function persistAnalyzedLocations(params: {
projectInternalId: string
existingNames: Set<string>
analyzedLocations: Record<string, unknown>[]
db?: LocationCreateDb
}) {
const created: Array<{ id: string; name: string }> = []
const invalidKeywords = ['幻想', '抽象', '无明确', '空间锚点', '未说明', '不明确']
const db = params.db ?? prisma
for (const item of params.analyzedLocations) {
const name = asString(item.name).trim()
@@ -106,7 +124,7 @@ export async function persistAnalyzedLocations(params: {
const key = name.toLowerCase()
if (params.existingNames.has(key)) continue
const location = await prisma.novelPromotionLocation.create({
const location = await db.novelPromotionLocation.create({
data: {
novelPromotionProjectId: params.projectInternalId,
name,
@@ -119,10 +137,13 @@ export async function persistAnalyzedLocations(params: {
})
const cleanDescriptions = mergedDescriptions.map((desc) => removeLocationPromptSuffix(desc || ''))
const availableSlots = normalizeLocationAvailableSlots(item.available_slots)
await seedProjectLocationBackedImageSlots({
locationId: location.id,
descriptions: cleanDescriptions,
fallbackDescription: asString(item.summary) || name,
availableSlots,
locationImageModel: db.locationImage,
})
params.existingNames.add(key)
@@ -136,8 +157,10 @@ export async function persistAnalyzedProps(params: {
projectInternalId: string
existingNames: Set<string>
analyzedProps: Record<string, unknown>[]
db?: LocationCreateDb
}) {
const created: Array<{ id: string; name: string }> = []
const db = params.db ?? prisma
for (const item of params.analyzedProps) {
const name = asString(item.name).trim()
@@ -147,7 +170,7 @@ export async function persistAnalyzedProps(params: {
const key = name.toLowerCase()
if (params.existingNames.has(key)) continue
const prop = await prisma.novelPromotionLocation.create({
const prop = await db.novelPromotionLocation.create({
data: {
novelPromotionProjectId: params.projectInternalId,
name,
@@ -163,6 +186,8 @@ export async function persistAnalyzedProps(params: {
locationId: prop.id,
descriptions: [summary],
fallbackDescription: summary,
availableSlots: [],
locationImageModel: db.locationImage,
})
params.existingNames.add(key)
@@ -175,12 +200,14 @@ export async function persistAnalyzedProps(params: {
export async function persistClips(params: {
episodeId: string
clipList: StoryToScriptClipCandidate[]
db?: ClipPersistDb
}) {
const clipModel = prisma.novelPromotionClip as unknown as {
const db = params.db ?? prisma
const clipModel = db.novelPromotionClip as unknown as {
update: (args: { where: { id: string }; data: Record<string, unknown>; select: { id: true } }) => Promise<{ id: string }>
create: (args: { data: Record<string, unknown>; select: { id: true } }) => Promise<{ id: string }>
findMany: typeof prisma.novelPromotionClip.findMany
deleteMany: typeof prisma.novelPromotionClip.deleteMany
findMany: typeof db.novelPromotionClip.findMany
deleteMany: typeof db.novelPromotionClip.deleteMany
}
const existing = await clipModel.findMany({
where: { episodeId: params.episodeId },

View File

@@ -344,37 +344,39 @@ export async function handleStoryToScriptTask(job: Job<TaskJobData>) {
},
})
let clipRecord = await prisma.novelPromotionClip.findFirst({
where: {
episodeId,
startText: asString(retryClip.startText) || null,
endText: asString(retryClip.endText) || null,
},
select: { id: true },
})
if (!clipRecord) {
const clipModel = prisma.novelPromotionClip as unknown as {
create: (args: { data: Record<string, unknown>; select: { id: true } }) => Promise<{ id: string }>
}
clipRecord = await clipModel.create({
data: {
await prisma.$transaction(async (tx) => {
let clipRecord = await tx.novelPromotionClip.findFirst({
where: {
episodeId,
startText: asString(retryClip.startText) || null,
endText: asString(retryClip.endText) || null,
summary: asString(retryClip.summary),
location: asString(retryClip.location) || null,
characters: Array.isArray(retryClip.characters) ? JSON.stringify(retryClip.characters) : null,
props: Array.isArray(retryClip.props) ? JSON.stringify(retryClip.props) : null,
content: clipContent,
},
select: { id: true },
})
}
await prisma.novelPromotionClip.update({
where: { id: clipRecord.id },
data: {
screenplay: JSON.stringify(screenplay),
},
if (!clipRecord) {
const clipModel = tx.novelPromotionClip as unknown as {
create: (args: { data: Record<string, unknown>; select: { id: true } }) => Promise<{ id: string }>
}
clipRecord = await clipModel.create({
data: {
episodeId,
startText: asString(retryClip.startText) || null,
endText: asString(retryClip.endText) || null,
summary: asString(retryClip.summary),
location: asString(retryClip.location) || null,
characters: Array.isArray(retryClip.characters) ? JSON.stringify(retryClip.characters) : null,
props: Array.isArray(retryClip.props) ? JSON.stringify(retryClip.props) : null,
content: clipContent,
},
select: { id: true },
})
}
await tx.novelPromotionClip.update({
where: { id: clipRecord.id },
data: {
screenplay: JSON.stringify(screenplay),
},
})
})
await reportTaskProgress(job, 96, {
@@ -529,40 +531,53 @@ export async function handleStoryToScriptTask(job: Job<TaskJobData>) {
.map((item) => String(item.name || '').toLowerCase()),
)
const createdCharacters = await persistAnalyzedCharacters({
projectInternalId: novelData.id,
existingNames: existingCharacterNames,
analyzedCharacters: result.analyzedCharacters,
})
const createdLocations = await persistAnalyzedLocations({
projectInternalId: novelData.id,
existingNames: existingLocationNames,
analyzedLocations: result.analyzedLocations,
})
const createdProps = await persistAnalyzedProps({
projectInternalId: novelData.id,
existingNames: existingPropNames,
analyzedProps: result.analyzedProps,
})
const createdClipRows = await persistClips({
episodeId,
clipList: result.clipList,
})
const clipIdMap = new Map(createdClipRows.map((item) => [item.clipKey, item.id]))
for (const screenplayResult of result.screenplayResults) {
if (!screenplayResult.success || !screenplayResult.screenplay) continue
const clipRecordId = resolveClipRecordId(clipIdMap, screenplayResult.clipId)
if (!clipRecordId) continue
await prisma.novelPromotionClip.update({
where: { id: clipRecordId },
data: {
screenplay: JSON.stringify(screenplayResult.screenplay),
},
const persistedResult = await prisma.$transaction(async (tx) => {
const createdCharacters = await persistAnalyzedCharacters({
projectInternalId: novelData.id,
existingNames: existingCharacterNames,
analyzedCharacters: result.analyzedCharacters,
db: tx,
})
}
const createdLocations = await persistAnalyzedLocations({
projectInternalId: novelData.id,
existingNames: existingLocationNames,
analyzedLocations: result.analyzedLocations,
db: tx,
})
const createdProps = await persistAnalyzedProps({
projectInternalId: novelData.id,
existingNames: existingPropNames,
analyzedProps: result.analyzedProps,
db: tx,
})
const createdClipRows = await persistClips({
episodeId,
clipList: result.clipList,
db: tx,
})
const clipIdMap = new Map(createdClipRows.map((item) => [item.clipKey, item.id]))
for (const screenplayResult of result.screenplayResults) {
if (!screenplayResult.success || !screenplayResult.screenplay) continue
const clipRecordId = resolveClipRecordId(clipIdMap, screenplayResult.clipId)
if (!clipRecordId) continue
await tx.novelPromotionClip.update({
where: { id: clipRecordId },
data: {
screenplay: JSON.stringify(screenplayResult.screenplay),
},
})
}
return {
createdCharacters,
createdLocations,
createdProps,
createdClipRows,
}
})
await reportTaskProgress(job, 96, {
stage: 'story_to_script_persist_done',
@@ -575,10 +590,10 @@ export async function handleStoryToScriptTask(job: Job<TaskJobData>) {
clipCount: result.summary.clipCount,
screenplaySuccessCount: result.summary.screenplaySuccessCount,
screenplayFailedCount: result.summary.screenplayFailedCount,
persistedCharacters: createdCharacters.length,
persistedLocations: createdLocations.length,
persistedProps: createdProps.length,
persistedClips: createdClipRows.length,
persistedCharacters: persistedResult.createdCharacters.length,
persistedLocations: persistedResult.createdLocations.length,
persistedProps: persistedResult.createdProps.length,
persistedClips: persistedResult.createdClipRows.length,
}
},
})

View File

@@ -8,6 +8,7 @@ import { QUEUE_NAME } from '@/lib/task/queues'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
import { buildInsertPanelLocationsDescription } from '@/lib/novel-promotion/insert-panel-prompt-context'
import {
executePhase1,
executePhase2,
@@ -530,14 +531,11 @@ async function handleInsertPanelTask(job: Job<TaskJobData>) {
})
.join('\n') || '无'
const locationsDescription = projectLocations
.filter((location) => relatedLocations.length === 0 || relatedLocations.includes(location.name))
.map((location) => {
const images = location.images || []
const selectedImage = images.find((img) => img.isSelected) || images[0]
return `${location.name}: ${selectedImage?.description || '无描述'}`
})
.join('\n') || '无'
const locationsDescription = buildInsertPanelLocationsDescription(
projectLocations,
relatedLocations,
job.data.locale,
)
const propsDescription = projectProps
.filter((prop) => relatedProps.length === 0 || relatedProps.includes(prop.name))
.map((prop) => `${prop.name}: ${prop.summary || '无描述'}`)

View File

@@ -1,95 +1,9 @@
const STORY_TO_SCRIPT_WORKFLOW = 'story_to_script_run'
const SCRIPT_TO_STORYBOARD_WORKFLOW = 'script_to_storyboard_run'
function uniqueStepKeys(stepKeys: Iterable<string>): string[] {
return Array.from(new Set(Array.from(stepKeys).filter((stepKey) => stepKey.trim().length > 0)))
}
function resolveStoryToScriptInvalidation(params: {
stepKey: string
existingStepKeys: ReadonlySet<string>
}): string[] {
const affected = new Set<string>([params.stepKey])
if (params.stepKey === 'analyze_characters' || params.stepKey === 'analyze_locations') {
if (params.existingStepKeys.has('split_clips')) {
affected.add('split_clips')
}
for (const stepKey of params.existingStepKeys) {
if (stepKey.startsWith('screenplay_')) {
affected.add(stepKey)
}
}
} else if (params.stepKey === 'split_clips') {
for (const stepKey of params.existingStepKeys) {
if (stepKey.startsWith('screenplay_')) {
affected.add(stepKey)
}
}
}
return uniqueStepKeys(affected)
}
type StoryboardPhase = 'phase1' | 'phase2_cinematography' | 'phase2_acting' | 'phase3_detail'
function parseStoryboardStepKey(stepKey: string): { clipId: string; phase: StoryboardPhase } | null {
const match = /^clip_(.+)_(phase1|phase2_cinematography|phase2_acting|phase3_detail)$/.exec(stepKey.trim())
if (!match) return null
const clipId = (match[1] || '').trim()
const phase = match[2] as StoryboardPhase
if (!clipId) return null
return { clipId, phase }
}
function resolveScriptToStoryboardInvalidation(params: {
stepKey: string
existingStepKeys: ReadonlySet<string>
}): string[] {
const affected = new Set<string>([params.stepKey])
if (params.stepKey === 'voice_analyze') {
return uniqueStepKeys(affected)
}
const parsed = parseStoryboardStepKey(params.stepKey)
if (!parsed) {
return uniqueStepKeys(affected)
}
const clipPrefix = `clip_${parsed.clipId}_`
if (parsed.phase === 'phase1') {
affected.add(`${clipPrefix}phase2_cinematography`)
affected.add(`${clipPrefix}phase2_acting`)
affected.add(`${clipPrefix}phase3_detail`)
affected.add('voice_analyze')
return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))
}
if (parsed.phase === 'phase2_cinematography' || parsed.phase === 'phase2_acting') {
affected.add(`${clipPrefix}phase3_detail`)
affected.add('voice_analyze')
return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))
}
affected.add('voice_analyze')
return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))
}
import { resolveWorkflowRetryInvalidationStepKeys } from './registry'
export function resolveRetryInvalidationStepKeys(params: {
workflowType: string
stepKey: string
existingStepKeys: string[]
}): string[] {
const existingStepKeys = new Set(params.existingStepKeys)
if (params.workflowType === STORY_TO_SCRIPT_WORKFLOW) {
return resolveStoryToScriptInvalidation({
stepKey: params.stepKey,
existingStepKeys,
})
}
if (params.workflowType === SCRIPT_TO_STORYBOARD_WORKFLOW) {
return resolveScriptToStoryboardInvalidation({
stepKey: params.stepKey,
existingStepKeys,
})
}
return uniqueStepKeys([params.stepKey].filter((stepKey) => existingStepKeys.has(stepKey)))
return resolveWorkflowRetryInvalidationStepKeys(params)
}

View File

@@ -0,0 +1,214 @@
import { TASK_TYPE } from '@/lib/task/types'
export type WorkflowFailureMode = 'fail_run'
export type WorkflowStepDefinition = {
key: string
dependsOn: string[]
retryable: boolean
artifactTypes: string[]
failureMode: WorkflowFailureMode
}
export type WorkflowDefinition = {
workflowType: string
orderedSteps: WorkflowStepDefinition[]
resolveRetryInvalidationStepKeys: (params: {
stepKey: string
existingStepKeys: string[]
}) => string[]
}
function uniqueStepKeys(stepKeys: Iterable<string>): string[] {
return Array.from(new Set(Array.from(stepKeys).filter((stepKey) => stepKey.trim().length > 0)))
}
function resolveStoryToScriptInvalidation(params: {
stepKey: string
existingStepKeys: ReadonlySet<string>
}): string[] {
const affected = new Set<string>([params.stepKey])
if (
params.stepKey === 'analyze_characters'
|| params.stepKey === 'analyze_locations'
|| params.stepKey === 'analyze_props'
) {
if (params.existingStepKeys.has('split_clips')) {
affected.add('split_clips')
}
for (const stepKey of params.existingStepKeys) {
if (stepKey.startsWith('screenplay_')) {
affected.add(stepKey)
}
}
} else if (params.stepKey === 'split_clips') {
for (const stepKey of params.existingStepKeys) {
if (stepKey.startsWith('screenplay_')) {
affected.add(stepKey)
}
}
}
return uniqueStepKeys(affected)
}
type StoryboardPhase = 'phase1' | 'phase2_cinematography' | 'phase2_acting' | 'phase3_detail'
function parseStoryboardStepKey(stepKey: string): { clipId: string; phase: StoryboardPhase } | null {
const match = /^clip_(.+)_(phase1|phase2_cinematography|phase2_acting|phase3_detail)$/.exec(stepKey.trim())
if (!match) return null
const clipId = (match[1] || '').trim()
const phase = match[2] as StoryboardPhase
if (!clipId) return null
return { clipId, phase }
}
function resolveScriptToStoryboardInvalidation(params: {
stepKey: string
existingStepKeys: ReadonlySet<string>
}): string[] {
const affected = new Set<string>([params.stepKey])
if (params.stepKey === 'voice_analyze') {
return uniqueStepKeys(affected)
}
const parsed = parseStoryboardStepKey(params.stepKey)
if (!parsed) {
return uniqueStepKeys(affected)
}
const clipPrefix = `clip_${parsed.clipId}_`
if (parsed.phase === 'phase1') {
affected.add(`${clipPrefix}phase2_cinematography`)
affected.add(`${clipPrefix}phase2_acting`)
affected.add(`${clipPrefix}phase3_detail`)
affected.add('voice_analyze')
return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))
}
if (parsed.phase === 'phase2_cinematography' || parsed.phase === 'phase2_acting') {
affected.add(`${clipPrefix}phase3_detail`)
affected.add('voice_analyze')
return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))
}
affected.add('voice_analyze')
return uniqueStepKeys(Array.from(affected).filter((stepKey) => params.existingStepKeys.has(stepKey)))
}
const STORY_TO_SCRIPT_DEFINITION: WorkflowDefinition = {
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
orderedSteps: [
{
key: 'analyze_characters',
dependsOn: [],
retryable: true,
artifactTypes: ['analysis.characters'],
failureMode: 'fail_run',
},
{
key: 'analyze_locations',
dependsOn: [],
retryable: true,
artifactTypes: ['analysis.locations'],
failureMode: 'fail_run',
},
{
key: 'analyze_props',
dependsOn: [],
retryable: true,
artifactTypes: ['analysis.props'],
failureMode: 'fail_run',
},
{
key: 'split_clips',
dependsOn: ['analyze_characters', 'analyze_locations', 'analyze_props'],
retryable: true,
artifactTypes: ['clips.split'],
failureMode: 'fail_run',
},
{
key: 'screenplay_convert',
dependsOn: ['split_clips'],
retryable: true,
artifactTypes: ['screenplay.clip'],
failureMode: 'fail_run',
},
{
key: 'persist_script_artifacts',
dependsOn: ['screenplay_convert'],
retryable: false,
artifactTypes: [],
failureMode: 'fail_run',
},
],
resolveRetryInvalidationStepKeys: ({ stepKey, existingStepKeys }) => resolveStoryToScriptInvalidation({
stepKey,
existingStepKeys: new Set(existingStepKeys),
}),
}
const SCRIPT_TO_STORYBOARD_DEFINITION: WorkflowDefinition = {
workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
orderedSteps: [
{
key: 'plan_panels',
dependsOn: [],
retryable: true,
artifactTypes: ['storyboard.clip.phase1'],
failureMode: 'fail_run',
},
{
key: 'detail_panels',
dependsOn: ['plan_panels'],
retryable: true,
artifactTypes: [
'storyboard.clip.phase2_cinematography',
'storyboard.clip.phase2_acting',
'storyboard.clip.phase3',
],
failureMode: 'fail_run',
},
{
key: 'voice_analyze',
dependsOn: ['detail_panels'],
retryable: true,
artifactTypes: ['voice.lines'],
failureMode: 'fail_run',
},
{
key: 'persist_storyboard_artifacts',
dependsOn: ['detail_panels', 'voice_analyze'],
retryable: false,
artifactTypes: [],
failureMode: 'fail_run',
},
],
resolveRetryInvalidationStepKeys: ({ stepKey, existingStepKeys }) => resolveScriptToStoryboardInvalidation({
stepKey,
existingStepKeys: new Set(existingStepKeys),
}),
}
const WORKFLOW_DEFINITIONS: Record<string, WorkflowDefinition> = {
[STORY_TO_SCRIPT_DEFINITION.workflowType]: STORY_TO_SCRIPT_DEFINITION,
[SCRIPT_TO_STORYBOARD_DEFINITION.workflowType]: SCRIPT_TO_STORYBOARD_DEFINITION,
}
export function getWorkflowDefinition(workflowType: string): WorkflowDefinition | null {
return WORKFLOW_DEFINITIONS[workflowType] || null
}
export function resolveWorkflowRetryInvalidationStepKeys(params: {
workflowType: string
stepKey: string
existingStepKeys: string[]
}): string[] {
const definition = getWorkflowDefinition(params.workflowType)
if (!definition) {
return uniqueStepKeys([params.stepKey].filter((stepKey) => params.existingStepKeys.includes(stepKey)))
}
return definition.resolveRetryInvalidationStepKeys({
stepKey: params.stepKey,
existingStepKeys: params.existingStepKeys,
})
}

View File

@@ -1,4 +1,5 @@
import type { CapabilitySelections } from '@/lib/model-config-contract'
import type { LocationAvailableSlot } from '@/lib/location-available-slots'
// ============================================
// 基础项目类型
@@ -80,6 +81,7 @@ export interface LocationImage {
locationId?: string // 可选API 响应可能不包含
imageIndex: number // 图片索引0, 1, 2
description: string | null
availableSlots?: LocationAvailableSlot[] | null
imageUrl: string | null
media?: MediaRef | null
previousImageUrl: string | null // 上一次的图片URL用于撤回