feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
|
||||
124
src/components/story-input/LongTextDetectionPrompt.tsx
Normal file
124
src/components/story-input/LongTextDetectionPrompt.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
22
src/lib/api/read-error-message.ts
Normal file
22
src/lib/api/read-error-message.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
45
src/lib/location-available-slots.ts
Normal file
45
src/lib/location-available-slots.ts
Normal 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')}`
|
||||
}
|
||||
32
src/lib/location-image-prompt.ts
Normal file
32
src/lib/location-image-prompt.ts
Normal 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()
|
||||
}
|
||||
46
src/lib/novel-promotion/insert-panel-prompt-context.ts
Normal file
46
src/lib/novel-promotion/insert-panel-prompt-context.ts
Normal 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')
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface PanelCharacterRef {
|
||||
name: string
|
||||
appearance: string
|
||||
slot?: string
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
74
src/lib/novel-promotion/stage-readiness.ts
Normal file
74
src/lib/novel-promotion/stage-readiness.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
11
src/lib/projects/default-name.ts
Normal file
11
src/lib/projects/default-name.ts
Normal 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}`
|
||||
}
|
||||
82
src/lib/projects/validation.ts
Normal file
82
src/lib/projects/validation.ts
Normal 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} 个字符。`
|
||||
}
|
||||
}
|
||||
90
src/lib/query/hooks/run-stream/recovery-probe.ts
Normal file
90
src/lib/query/hooks/run-stream/recovery-probe.ts
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
305
src/lib/run-runtime/reconcile.ts
Normal file
305
src/lib/run-runtime/reconcile.ts
Normal 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
|
||||
}
|
||||
85
src/lib/run-runtime/recovery.ts
Normal file
85
src/lib/run-runtime/recovery.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -85,6 +85,8 @@ export type ListRunsInput = {
|
||||
episodeId?: string
|
||||
statuses?: RunStatus[]
|
||||
limit?: number
|
||||
recoverableOnly?: boolean
|
||||
latestOnly?: boolean
|
||||
}
|
||||
|
||||
export type StateRef = {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,5 +68,6 @@ export async function handleAssetHubAIDesignTask(job: Job<TaskJobData>) {
|
||||
|
||||
return {
|
||||
prompt: result.prompt,
|
||||
availableSlots: result.availableSlots ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
} : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
} : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '与参考图风格一致',
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 || '无描述'}`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
214
src/lib/workflow-engine/registry.ts
Normal file
214
src/lib/workflow-engine/registry.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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(用于撤回)
|
||||
|
||||
Reference in New Issue
Block a user