Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions tests/test_story_lab_audio_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ def stories_ui() -> str:
return "\n".join(path.read_text(encoding="utf-8") for path in sorted(STORIES.glob("*.tsx")))


CATALOG_EN = ROOT / "ui" / "src" / "i18n" / "locales" / "en" / "storyLab.json"


STORY_VIDEO_FORMAT = ROOT / "ui" / "src" / "features" / "stories" / "StoryVideoFormatControls.tsx"
STORY_VIDEO_FORMAT_HELPERS = ROOT / "ui" / "src" / "features" / "stories" / "storyLabVideoFormat.ts"
STORY_GALLERY = ROOT / "ui" / "src" / "features" / "stories" / "ReferenceGallery.tsx"
Expand Down Expand Up @@ -152,12 +155,13 @@ def test_story_assets_support_reviewed_non_destructive_style_variants():
assert "approval: StoryApprovalState" in types
assert "derivedFromAssetId?: string" in types
assert "stylePrompt?: string" in types
assert "Convert selected images to a style" in panel
catalog = CATALOG_EN.read_text(encoding="utf-8")
assert "Convert selected images to a style" in catalog
assert "QWEN_STYLE_EDIT_MODEL = 'qwen_image_edit_20B_gguf_q4_k_m'" in panel
assert "FLUX_STYLE_EDIT_MODEL = 'flux2_klein_9b'" in panel
assert "Style conversion model" in panel
assert "MiniMax Image-01 · characters only" in panel
assert "Install selected local editor" in panel
assert "Style conversion model" in catalog
assert "MiniMax Image-01 · characters only" in catalog
assert "Install selected local editor" in catalog
assert "Review and approve only the images Director should use" in panel
assert "approval: item.approval === 'draft' ? 'draft' : 'approved'" in model

Expand All @@ -175,9 +179,9 @@ def test_story_library_can_bulk_remove_only_selected_drafts():
assert "character.referenceAssetIds.filter" in deletion
assert "delete current.assets[id]" in deletion
assert "Generated files remain in Gallery" in deletion
assert "Delete selected Draft" in panel
assert "Delete selected Draft" in CATALOG_EN.read_text(encoding="utf-8")
assert "visualAssetsNewestFirst" in panel
assert "Newest images appear first" in panel
assert "Newest images appear first" in CATALOG_EN.read_text(encoding="utf-8")


def test_story_reference_images_confirm_removal_and_open_in_a_modal():
Expand Down Expand Up @@ -207,7 +211,7 @@ def test_story_style_converter_warns_about_photo_to_photo_noops_and_honors_reque
assert "normalizedStyle" in prompt_builder
assert ".replace(/\\s+/g, ' ')" in prompt_builder
assert "Render only the visible wording explicitly requested" in prompt_builder
assert "a photorealistic remake will look almost unchanged" in panel
assert "a photorealistic remake will look almost unchanged" in CATALOG_EN.read_text(encoding="utf-8")


def test_story_style_conversion_uses_true_qwen_edit_semantics_for_scenes():
Expand All @@ -229,7 +233,7 @@ def test_story_style_conversion_uses_flux_klein_as_a_true_four_step_image_editor

assert "styleUsesFlux" in panel
assert "styleUsesFlux ? 'flux' : 'qwen'" in panel
assert "fast 4-step edit" in panel
assert "fast 4-step edit" in CATALOG_EN.read_text(encoding="utf-8")
assert "selected === 'flux2_klein_9b'" in generation
assert "referenceParams.num_inference_steps = 4" in generation
assert "referenceParams.guidance_scale = 1" in generation
Expand Down
5 changes: 5 additions & 0 deletions ui/scripts/check-i18n-catalogs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ const PILOT_FILES = [
'src/features/workspaces/WorkspacesPanel.tsx',
'src/features/stories/storyLabChrome.tsx',
'src/features/stories/StoryOverviewTab.tsx',
'src/features/stories/StoryAssetsTab.tsx',
'src/features/stories/StoryAssetsImporter.tsx',
'src/features/stories/StoryAssetsProposalCard.tsx',
'src/features/stories/StoryAssetsStyleConverter.tsx',
'src/features/stories/StoryAssetsLibrary.tsx',
'src/features/stories/StoryProviderPanel.tsx',
'src/features/stories/StoryProviderWritingFields.tsx',
'src/features/stories/StoryProviderImageFields.tsx',
Expand Down
100 changes: 100 additions & 0 deletions ui/src/features/stories/StoryAssetsImporter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { type RefObject } from 'react'
import { Check, Loader2, Upload } from 'lucide-react'
import { useUiTranslation } from '../../i18n'
import { button, panel, Field } from './storyLabChrome'
import { StoryAssetsProposalCard } from './StoryAssetsProposalCard'
import type { PendingSmartAsset } from './storyLabAssets'
import type { StoryProject } from './types'

export function StoryAssetsImporter({
project, smartAssetBusy, smartAssetDescription, setSmartAssetDescription, smartAssetRef,
pendingSmartAssets, setPendingSmartAssets, analyzeSmartAssets, applySmartAssets, patchPendingSmartAsset,
}: {
project: StoryProject
smartAssetBusy: boolean
smartAssetDescription: string
setSmartAssetDescription: (value: string) => void
smartAssetRef: RefObject<HTMLInputElement | null>
pendingSmartAssets: PendingSmartAsset[]
setPendingSmartAssets: (value: PendingSmartAsset[]) => void
analyzeSmartAssets: (files: File[]) => void
applySmartAssets: () => void
patchPendingSmartAsset: (index: number, patch: Partial<PendingSmartAsset>) => void
}) {
const { t } = useUiTranslation('storyLab')
const analyzer = project.provider.writingProvider === 'maestro'
? t('assets.analyzerInternal')
: t('assets.analyzerExternal', {
provider: project.provider.writingProvider,
model: project.provider.writingModel || t('assets.configuredModel'),
})
return (
<>
<div className="mb-4 flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div>
<h2 className="text-lg font-semibold text-text-primary">{t('assets.title')}</h2>
<p className="mt-1 max-w-3xl text-xs text-text-muted">{t('assets.description')}</p>
</div>
<div className="rounded-md border border-border bg-bg-tertiary px-3 py-2 text-[10px] text-text-muted">
{analyzer}
</div>
</div>

<div className={`${panel} mb-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]`}>
<button
type="button"
disabled={smartAssetBusy}
className="min-h-44 rounded-xl border-2 border-dashed border-border bg-bg-tertiary/40 p-6 text-center transition-colors hover:border-accent-blue hover:bg-accent-blue/5 disabled:opacity-50"
onClick={() => smartAssetRef.current?.click()}
onDragOver={event => event.preventDefault()}
onDrop={event => {
event.preventDefault()
void analyzeSmartAssets(Array.from(event.dataTransfer.files))
}}
>
{smartAssetBusy
? <Loader2 size={28} className="mx-auto mb-3 animate-spin text-accent-blue" />
: <Upload size={28} className="mx-auto mb-3 text-accent-blue" />}
<span className="block text-sm font-medium text-text-primary">
{smartAssetBusy ? t('assets.uploading') : t('assets.drop')}
</span>
<span className="mt-2 block text-[10px] text-text-muted">{t('assets.dropHint')}</span>
</button>
<div>
<Field
label={t('assets.batchContext')}
value={smartAssetDescription}
onChange={setSmartAssetDescription}
rows={6}
placeholder={t('assets.batchContextPlaceholder')}
/>
<p className="mt-2 text-[9px] text-text-muted">{t('assets.batchContextHint')}</p>
</div>
</div>

{pendingSmartAssets.length > 0 && (
<section className="mb-5">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('assets.reviewTitle')}</h3>
<p className="text-[10px] text-text-muted">{t('assets.reviewHint')}</p>
</div>
<div className="flex gap-2">
<button className={button} onClick={() => setPendingSmartAssets([])}>{t('assets.discardBatch')}</button>
<button className={`${button} border-emerald-500/50 text-emerald-300`}
disabled={!pendingSmartAssets.some(item => item.selected && item.kind !== 'ignore')}
onClick={applySmartAssets}>
<Check size={13} /> {t('assets.applySelected')}
</button>
</div>
</div>
<div className="space-y-3">
{pendingSmartAssets.map((item, index) => (
<StoryAssetsProposalCard key={`${item.source}-${index}`} item={item} index={index} project={project} onPatch={patchPendingSmartAsset} />
))}
</div>
</section>
)}
</>
)
}
99 changes: 99 additions & 0 deletions ui/src/features/stories/StoryAssetsLibrary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Check, Trash2 } from 'lucide-react'
import { useUiTranslation } from '../../i18n'
import { button, input, panel } from './storyLabChrome'
import type { StoryProject, StoryVisualAsset } from './types'

export function StoryAssetsLibrary({
project, styleAssetIds, setStyleAssetIds, selectedDraftAssetIds, styleConversionBusy,
deleteSelectedDraftAssets, toggleStyleAsset, patchVisualAsset, visualAssetsNewestFirst,
}: {
project: StoryProject
styleAssetIds: string[]
setStyleAssetIds: (ids: string[]) => void
selectedDraftAssetIds: string[]
styleConversionBusy: boolean
deleteSelectedDraftAssets: () => void
toggleStyleAsset: (id: string) => void
patchVisualAsset: (id: string, patch: Partial<StoryVisualAsset>) => void
visualAssetsNewestFirst: StoryVisualAsset[]
}) {
const { t } = useUiTranslation('storyLab')
const assetIds = Object.keys(project.assets)
const approvedCount = Object.values(project.assets).filter(asset => asset.approval === 'approved').length
const allSelected = styleAssetIds.length === assetIds.length
return (
<section>
<div className="mb-2 flex flex-wrap items-end justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('assets.libraryTitle', { count: assetIds.length })}</h3>
<p className="mt-0.5 text-[9px] text-text-muted">
{t('assets.libraryHint', { approved: approvedCount })}
</p>
</div>
{assetIds.length > 0 && (
<div className="flex flex-wrap gap-2">
<button
className={button}
onClick={() => setStyleAssetIds(allSelected ? [] : assetIds)}
>
{allSelected ? t('assets.clearSelection') : t('assets.selectAll')}
</button>
<button
className={`${button} border-red-500/60 text-red-300`}
disabled={!selectedDraftAssetIds.length || styleConversionBusy}
onClick={deleteSelectedDraftAssets}
title={t('assets.deleteDraftsTitle')}
>
<Trash2 size={13} /> {t('assets.deleteDrafts', { count: selectedDraftAssetIds.length })}
</button>
</div>
)}
</div>
{assetIds.length ? (
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
{visualAssetsNewestFirst.map(asset => (
<div key={asset.id} className={`${panel} p-2.5 ${asset.approval === 'approved' ? 'border-emerald-500/40' : ''}`}>
<div className="relative">
<img src={asset.source} alt={asset.name} className="h-44 w-full rounded-md border border-border object-cover" />
<label className="absolute left-2 top-2 flex items-center gap-1.5 rounded bg-black/75 px-2 py-1 text-[9px] text-white">
<input type="checkbox" checked={styleAssetIds.includes(asset.id)} onChange={() => toggleStyleAsset(asset.id)} />
{t('assets.select')}
</label>
<span className={`absolute right-2 top-2 rounded border px-1.5 py-0.5 text-[9px] ${asset.approval === 'approved'
? 'border-emerald-400/70 bg-emerald-950/80 text-emerald-200'
: 'border-amber-400/60 bg-amber-950/80 text-amber-200'}`}>
{asset.approval === 'approved' ? t('status.approved') : t('status.draft')}
</span>
</div>
<input className={`${input} mt-2`} value={asset.name}
onChange={event => patchVisualAsset(asset.id, { name: event.target.value })}
aria-label={t('assets.nameAria', { name: asset.name })} />
<div className="mt-1 flex flex-wrap items-center gap-1 text-[9px] uppercase tracking-wide text-text-muted">
<span>{asset.assetKind || asset.provider}</span>
<span>·</span>
<span>{asset.variantKind === 'styled' ? t('assets.styledVariant') : t('assets.original')}</span>
{asset.model && <><span>·</span><span>{asset.provider}/{asset.model}</span></>}
<span>·</span><span>{new Date(asset.createdAt).toLocaleString()}</span>
</div>
<textarea className={`${input} mt-2 min-h-16 resize-y`} value={asset.description || ''}
onChange={event => patchVisualAsset(asset.id, { description: event.target.value })}
placeholder={t('assets.descriptionPlaceholder')} aria-label={t('assets.descriptionAria', { name: asset.name })} />
<textarea className={`${input} mt-2 min-h-20 resize-y`} value={asset.prompt}
onChange={event => patchVisualAsset(asset.id, { prompt: event.target.value })}
placeholder={t('assets.promptPlaceholder')} aria-label={t('assets.promptAria', { name: asset.name })} />
{asset.stylePrompt && <p className="mt-1 text-[9px] text-violet-200">{t('assets.styleLabel', { style: asset.stylePrompt })}</p>}
<button
className={`${button} mt-2 w-full ${asset.approval === 'approved' ? 'border-emerald-500/60 text-emerald-300' : 'border-amber-500/50 text-amber-200'}`}
onClick={() => patchVisualAsset(asset.id, {
approval: asset.approval === 'approved' ? 'draft' : 'approved',
})}
>
<Check size={13} /> {asset.approval === 'approved' ? t('assets.approvedProduction') : t('assets.approveProduction')}
</button>
</div>
))}
</div>
) : <div className={`${panel} py-10 text-center text-xs text-text-muted`}>{t('assets.empty')}</div>}
</section>
)
}
85 changes: 85 additions & 0 deletions ui/src/features/stories/StoryAssetsProposalCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useUiTranslation } from '../../i18n'
import { input, panel, Field } from './storyLabChrome'
import { storyAssetKey, type PendingSmartAsset } from './storyLabAssets'
import type { StoryAssetKind, StoryProject } from './types'

export function StoryAssetsProposalCard({
item, index, project, onPatch,
}: {
item: PendingSmartAsset
index: number
project: StoryProject
onPatch: (index: number, patch: Partial<PendingSmartAsset>) => void
}) {
const { t } = useUiTranslation('storyLab')
const newKey = storyAssetKey(`${item.name}-${index}`)
const targetOptions = item.kind === 'character'
? [
...project.characters.map(character => ({ id: character.id, label: t('assets.existingNamed', { name: character.name }) })),
{ id: item.targetId.startsWith('new-character:') ? item.targetId : `new-character:${newKey}`, label: t('assets.newCharacter', { name: item.name }) },
]
: item.kind === 'location'
? [
...project.world.locations.map(location => ({ id: location.id, label: t('assets.existingNamed', { name: location.name }) })),
{ id: item.targetId.startsWith('new-location:') ? item.targetId : `new-location:${newKey}`, label: t('assets.newLocation', { name: item.name }) },
]
: [{
id: 'world',
label: item.kind === 'prop' ? t('assets.worldProp') : item.kind === 'style' ? t('assets.worldStyle') : t('assets.worldRefs'),
}]
return (
<article className={`${panel} ${item.selected ? '' : 'opacity-60'}`}>
<div className="grid gap-3 lg:grid-cols-[140px_minmax(0,1fr)_minmax(260px,0.7fr)]">
<div>
<img src={item.source} alt={item.name} className="h-32 w-full rounded-lg border border-border object-cover" />
<label className="mt-2 flex items-center gap-2 text-[10px] text-text-secondary">
<input type="checkbox" checked={item.selected}
onChange={event => onPatch(index, { selected: event.target.checked })} />
{t('assets.importThis')}
</label>
<p className="mt-1 truncate text-[9px] text-text-muted" title={item.nameOriginal}>{item.nameOriginal}</p>
</div>
<div className="space-y-3">
<Field label={t('assets.editableName')} value={item.name}
onChange={name => onPatch(index, { name })} />
<Field label={t('assets.contains')} value={item.description}
onChange={description => onPatch(index, { description })} rows={3} />
<Field label={t('assets.visualPrompt')} value={item.visualPrompt}
onChange={visualPrompt => onPatch(index, { visualPrompt })} rows={3} />
</div>
<div className="space-y-3">
<label className="block text-[10px] text-text-muted">{t('assets.assetType')}
<select className={`${input} mt-1`} value={item.kind} onChange={event => {
const kind = event.target.value as StoryAssetKind
const targetId = kind === 'character'
? (project.characters[0]?.id || `new-character:${newKey}`)
: kind === 'location'
? (project.world.locations[0]?.id || `new-location:${newKey}`)
: 'world'
onPatch(index, { kind, targetId, selected: kind !== 'ignore' })
}}>
<option value="character">{t('assets.typeCharacter')}</option>
<option value="location">{t('assets.typeLocation')}</option>
<option value="world">{t('assets.typeWorld')}</option>
<option value="prop">{t('assets.typeProp')}</option>
<option value="style">{t('assets.typeStyle')}</option>
<option value="ignore">{t('assets.typeIgnore')}</option>
</select>
</label>
{item.kind !== 'ignore' && (
<label className="block text-[10px] text-text-muted">{t('assets.destination')}
<select className={`${input} mt-1`} value={targetOptions.some(option => option.id === item.targetId) ? item.targetId : targetOptions[0]?.id}
onChange={event => onPatch(index, { targetId: event.target.value })}>
{targetOptions.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
</label>
)}
<div className="rounded-md border border-border bg-bg-tertiary/60 p-2 text-[9px] text-text-muted">
<p>{t('assets.confidence', { percent: Math.round(item.confidence * 100) })}</p>
<p className="mt-1">{item.reason}</p>
</div>
</div>
</div>
</article>
)
}
Loading