478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { z } from 'zod'
|
|
import { clampKeyframeStrength } from '../lib/keyframe-strength.ts'
|
|
|
|
export const generationModeValues = [
|
|
'text-to-video',
|
|
'image-to-video',
|
|
'audio-to-video',
|
|
'multi-keyframe',
|
|
'text-to-image',
|
|
'image-edit',
|
|
'retake',
|
|
'extend',
|
|
'ic-lora',
|
|
] as const
|
|
|
|
export const assetTypeValues = ['image', 'video', 'audio', 'adjustment'] as const
|
|
export const timelineClipTypeValues = [...assetTypeValues, 'text'] as const
|
|
export const transitionTypeValues = [
|
|
'none',
|
|
'dissolve',
|
|
'fade-to-black',
|
|
'fade-to-white',
|
|
'wipe-left',
|
|
'wipe-right',
|
|
'wipe-up',
|
|
'wipe-down',
|
|
] as const
|
|
export const trackTypeValues = ['default', 'subtitle'] as const
|
|
export const trackKindValues = ['video', 'audio'] as const
|
|
export const subtitlePositionValues = ['bottom', 'top', 'center'] as const
|
|
export const fontWeightValues = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'] as const
|
|
export const fontStyleValues = ['normal', 'italic'] as const
|
|
export const textAlignValues = ['left', 'center', 'right'] as const
|
|
export const effectTypeValues = [
|
|
'blur',
|
|
'sharpen',
|
|
'glow',
|
|
'vignette',
|
|
'grain',
|
|
'lut-cinematic',
|
|
'lut-vintage',
|
|
'lut-bw',
|
|
'lut-cool',
|
|
'lut-warm',
|
|
'lut-muted',
|
|
'lut-vivid',
|
|
] as const
|
|
export const effectMaskShapeValues = ['rectangle', 'ellipse'] as const
|
|
export const letterboxAspectRatioValues = ['2.35:1', '2.39:1', '2.76:1', '1.85:1', '4:3', 'custom'] as const
|
|
export const viewTypeValues = ['home', 'project'] as const
|
|
export const projectTabValues = ['gen-space', 'video-editor'] as const
|
|
|
|
export const transitionTypeSchema = z.enum(transitionTypeValues)
|
|
export const viewTypeSchema = z.enum(viewTypeValues)
|
|
export const projectTabSchema = z.enum(projectTabValues)
|
|
|
|
export const persistedKeyframeSchema = z.object({
|
|
path: z.string(),
|
|
frameIndex: z.number(),
|
|
// Missing on #154 assets. Out of range is clamped so a bad float cannot fail the project.
|
|
strength: z.unknown().optional().transform(clampKeyframeStrength),
|
|
})
|
|
|
|
export const generationParamsSchema = z.object({
|
|
mode: z.enum(generationModeValues),
|
|
prompt: z.string(),
|
|
model: z.string(),
|
|
// Local pipeline ids ("fast") are shared across LTX versions, so `model` alone can't say
|
|
// which version produced the asset. Captured at generation time from the backend spec's
|
|
// display name; absent on assets written before this existed.
|
|
modelLabel: z.string().optional(),
|
|
duration: z.number().nullable(),
|
|
resolution: z.string(),
|
|
fps: z.number(),
|
|
audio: z.boolean(),
|
|
cameraMotion: z.string(),
|
|
imageAspectRatio: z.string().optional(),
|
|
imageSteps: z.number().optional(),
|
|
imageEditStrength: z.number().optional(),
|
|
inputImageUrl: z.string().optional(),
|
|
inputLastImageUrl: z.string().optional(),
|
|
inputAudioUrl: z.string().optional(),
|
|
keyframes: z.array(persistedKeyframeSchema).optional(),
|
|
retakeVideoPath: z.string().optional(),
|
|
retakeStartTime: z.number().optional(),
|
|
retakeDuration: z.number().optional(),
|
|
retakeMode: z.string().optional(),
|
|
extendVideoPath: z.string().optional(),
|
|
extendDuration: z.number().optional(),
|
|
extendDirection: z.string().optional(),
|
|
icLoraVideoPath: z.string().optional(),
|
|
icLoraConditioningType: z.string().optional(),
|
|
icLoraConditioningStrength: z.number().optional(),
|
|
loras: z.array(z.object({
|
|
ref: z.string(),
|
|
name: z.string(),
|
|
scale: z.number(),
|
|
})).optional(),
|
|
})
|
|
|
|
export const assetTakeSchema = z.object({
|
|
path: z.string(),
|
|
bigThumbnailPath: z.string().optional(),
|
|
smallThumbnailPath: z.string().optional(),
|
|
width: z.number().optional(),
|
|
height: z.number().optional(),
|
|
createdAt: z.number(),
|
|
})
|
|
|
|
export const subtitleStyleSchema = z.object({
|
|
fontSize: z.number(),
|
|
fontFamily: z.string(),
|
|
fontWeight: z.enum(['normal', 'bold']),
|
|
color: z.string(),
|
|
backgroundColor: z.string(),
|
|
position: z.enum(subtitlePositionValues),
|
|
italic: z.boolean(),
|
|
})
|
|
|
|
export const DEFAULT_SUBTITLE_STYLE = subtitleStyleSchema.parse({
|
|
fontSize: 32,
|
|
fontFamily: 'sans-serif',
|
|
fontWeight: 'normal',
|
|
color: '#FFFFFF',
|
|
backgroundColor: 'transparent',
|
|
position: 'bottom',
|
|
italic: false,
|
|
})
|
|
|
|
export const trackSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
muted: z.boolean(),
|
|
locked: z.boolean(),
|
|
solo: z.boolean().optional(),
|
|
enabled: z.boolean().optional(),
|
|
sourcePatched: z.boolean().optional(),
|
|
type: z.enum(trackTypeValues).optional(),
|
|
kind: z.enum(trackKindValues).optional(),
|
|
subtitleStyle: subtitleStyleSchema.partial().optional(),
|
|
})
|
|
|
|
export const DEFAULT_TRACKS = trackSchema.array().parse([
|
|
{ id: 'track-v1', name: 'V1', muted: false, locked: false, sourcePatched: true, kind: 'video' },
|
|
{ id: 'track-v2', name: 'V2', muted: false, locked: false, sourcePatched: false, kind: 'video' },
|
|
{ id: 'track-v3', name: 'V3', muted: false, locked: false, sourcePatched: false, kind: 'video' },
|
|
{ id: 'track-a1', name: 'A1', muted: false, locked: false, sourcePatched: true, kind: 'audio' },
|
|
{ id: 'track-a2', name: 'A2', muted: false, locked: false, sourcePatched: false, kind: 'audio' },
|
|
])
|
|
|
|
export const subtitleClipSchema = z.object({
|
|
id: z.string(),
|
|
text: z.string(),
|
|
startTime: z.number(),
|
|
endTime: z.number(),
|
|
trackIndex: z.number(),
|
|
style: subtitleStyleSchema.partial().optional(),
|
|
})
|
|
|
|
export const clipTransitionSchema = z.object({
|
|
type: transitionTypeSchema,
|
|
duration: z.number(),
|
|
})
|
|
|
|
export const DEFAULT_CLIP_TRANSITION = clipTransitionSchema.parse({
|
|
type: 'none',
|
|
duration: 0.5,
|
|
})
|
|
|
|
export const colorCorrectionSchema = z.object({
|
|
brightness: z.number(),
|
|
contrast: z.number(),
|
|
saturation: z.number(),
|
|
temperature: z.number(),
|
|
tint: z.number(),
|
|
exposure: z.number(),
|
|
highlights: z.number(),
|
|
shadows: z.number(),
|
|
})
|
|
|
|
export const DEFAULT_COLOR_CORRECTION = colorCorrectionSchema.parse({
|
|
brightness: 0,
|
|
contrast: 0,
|
|
saturation: 0,
|
|
temperature: 0,
|
|
tint: 0,
|
|
exposure: 0,
|
|
highlights: 0,
|
|
shadows: 0,
|
|
})
|
|
|
|
export const letterboxSettingsSchema = z.object({
|
|
enabled: z.boolean(),
|
|
aspectRatio: z.enum(letterboxAspectRatioValues),
|
|
customRatio: z.number().optional(),
|
|
color: z.string(),
|
|
opacity: z.number(),
|
|
})
|
|
|
|
export const DEFAULT_LETTERBOX = letterboxSettingsSchema.parse({
|
|
enabled: false,
|
|
aspectRatio: '2.35:1',
|
|
color: '#000000',
|
|
opacity: 100,
|
|
})
|
|
|
|
export const effectMaskSchema = z.object({
|
|
enabled: z.boolean(),
|
|
shape: z.enum(effectMaskShapeValues),
|
|
x: z.number(),
|
|
y: z.number(),
|
|
width: z.number(),
|
|
height: z.number(),
|
|
feather: z.number(),
|
|
invert: z.boolean(),
|
|
rotation: z.number(),
|
|
})
|
|
|
|
export const DEFAULT_EFFECT_MASK = effectMaskSchema.parse({
|
|
enabled: false,
|
|
shape: 'ellipse',
|
|
x: 50,
|
|
y: 50,
|
|
width: 40,
|
|
height: 40,
|
|
feather: 20,
|
|
invert: false,
|
|
rotation: 0,
|
|
})
|
|
|
|
export const clipEffectSchema = z.object({
|
|
id: z.string(),
|
|
type: z.enum(effectTypeValues),
|
|
enabled: z.boolean(),
|
|
params: z.record(z.string(), z.number()),
|
|
mask: effectMaskSchema.optional(),
|
|
})
|
|
|
|
export const textOverlayStyleSchema = z.object({
|
|
text: z.string(),
|
|
fontFamily: z.string(),
|
|
fontSize: z.number(),
|
|
fontWeight: z.enum(fontWeightValues),
|
|
fontStyle: z.enum(fontStyleValues),
|
|
color: z.string(),
|
|
backgroundColor: z.string(),
|
|
textAlign: z.enum(textAlignValues),
|
|
positionX: z.number(),
|
|
positionY: z.number(),
|
|
strokeColor: z.string(),
|
|
strokeWidth: z.number(),
|
|
shadowColor: z.string(),
|
|
shadowBlur: z.number(),
|
|
shadowOffsetX: z.number(),
|
|
shadowOffsetY: z.number(),
|
|
letterSpacing: z.number(),
|
|
lineHeight: z.number(),
|
|
maxWidth: z.number(),
|
|
padding: z.number(),
|
|
borderRadius: z.number(),
|
|
opacity: z.number(),
|
|
})
|
|
|
|
export const DEFAULT_TEXT_STYLE = textOverlayStyleSchema.parse({
|
|
text: 'Title Text',
|
|
fontFamily: 'Inter, Arial, sans-serif',
|
|
fontSize: 64,
|
|
fontWeight: 'bold',
|
|
fontStyle: 'normal',
|
|
color: '#FFFFFF',
|
|
backgroundColor: 'transparent',
|
|
textAlign: 'center',
|
|
positionX: 50,
|
|
positionY: 50,
|
|
strokeColor: 'transparent',
|
|
strokeWidth: 0,
|
|
shadowColor: 'rgba(0,0,0,0.5)',
|
|
shadowBlur: 4,
|
|
shadowOffsetX: 2,
|
|
shadowOffsetY: 2,
|
|
letterSpacing: 0,
|
|
lineHeight: 1.2,
|
|
maxWidth: 80,
|
|
padding: 0,
|
|
borderRadius: 0,
|
|
opacity: 100,
|
|
})
|
|
|
|
export const assetSchema = z.object({
|
|
id: z.string(),
|
|
type: z.enum(assetTypeValues),
|
|
path: z.string(),
|
|
bigThumbnailPath: z.string().optional(),
|
|
smallThumbnailPath: z.string().optional(),
|
|
width: z.number().optional(),
|
|
height: z.number().optional(),
|
|
prompt: z.string(),
|
|
resolution: z.string(),
|
|
duration: z.number().optional(),
|
|
createdAt: z.number(),
|
|
favorite: z.boolean().optional(),
|
|
binId: z.string().optional(),
|
|
generationParams: generationParamsSchema.optional(),
|
|
takes: z.array(assetTakeSchema).optional(),
|
|
activeTakeIndex: z.number().optional(),
|
|
colorLabel: z.string().optional(),
|
|
})
|
|
|
|
export const timelineClipSchema = z.object({
|
|
id: z.string(),
|
|
assetId: z.string().nullable(),
|
|
type: z.enum(timelineClipTypeValues),
|
|
startTime: z.number(),
|
|
duration: z.number(),
|
|
trimStart: z.number(),
|
|
trimEnd: z.number(),
|
|
speed: z.number().default(1),
|
|
reversed: z.boolean().default(false),
|
|
muted: z.boolean().default(false),
|
|
volume: z.number().default(100),
|
|
trackIndex: z.number(),
|
|
asset: assetSchema.nullable(),
|
|
importedName: z.string().optional(),
|
|
flipH: z.boolean().default(false),
|
|
flipV: z.boolean().default(false),
|
|
transitionIn: clipTransitionSchema.default(DEFAULT_CLIP_TRANSITION),
|
|
transitionOut: clipTransitionSchema.default(DEFAULT_CLIP_TRANSITION),
|
|
colorCorrection: colorCorrectionSchema.default(DEFAULT_COLOR_CORRECTION),
|
|
opacity: z.number().default(100),
|
|
takeIndex: z.number().optional(),
|
|
isRegenerating: z.boolean().optional(),
|
|
linkedClipIds: z.array(z.string()).optional(),
|
|
colorLabel: z.string().optional(),
|
|
effects: z.array(clipEffectSchema).optional(),
|
|
letterbox: letterboxSettingsSchema.optional(),
|
|
textStyle: textOverlayStyleSchema.optional(),
|
|
})
|
|
|
|
export const timelineSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
createdAt: z.number(),
|
|
tracks: z.array(trackSchema),
|
|
clips: z.array(timelineClipSchema),
|
|
subtitles: z.array(subtitleClipSchema).default([]),
|
|
})
|
|
|
|
export const assetBinsSchema = z.record(z.string(), z.string())
|
|
|
|
export const projectV2Schema = z.object({
|
|
version: z.literal(2),
|
|
id: z.string(),
|
|
name: z.string(),
|
|
createdAt: z.number(),
|
|
updatedAt: z.number(),
|
|
bins: assetBinsSchema,
|
|
assets: z.array(assetSchema),
|
|
timelines: z.array(timelineSchema),
|
|
activeTimelineId: z.string().optional(),
|
|
})
|
|
|
|
const assetV1Schema = assetSchema
|
|
.omit({ binId: true })
|
|
.extend({
|
|
bin: z.string().optional(),
|
|
})
|
|
|
|
export const projectV1Schema = projectV2Schema
|
|
.omit({ version: true, bins: true, assets: true, timelines: true })
|
|
.extend({
|
|
version: z.undefined().optional(),
|
|
bins: z.undefined().optional(),
|
|
assets: z.array(assetV1Schema),
|
|
timelines: z.array(timelineSchema).optional(),
|
|
})
|
|
|
|
export const projectSchema = projectV2Schema
|
|
|
|
export const projectReferenceSchema = z.object({
|
|
id: z.string(),
|
|
})
|
|
|
|
const projectVersionSchema = z.object({
|
|
version: z.unknown().optional(),
|
|
})
|
|
|
|
export type GenerationParams = z.infer<typeof generationParamsSchema>
|
|
export type AssetTake = z.infer<typeof assetTakeSchema>
|
|
export type Asset = z.infer<typeof assetSchema>
|
|
export type Track = z.infer<typeof trackSchema>
|
|
export type SubtitleStyle = z.infer<typeof subtitleStyleSchema>
|
|
export type SubtitleClip = z.infer<typeof subtitleClipSchema>
|
|
export type TransitionType = z.infer<typeof transitionTypeSchema>
|
|
export type ClipTransition = z.infer<typeof clipTransitionSchema>
|
|
export type ColorCorrection = z.infer<typeof colorCorrectionSchema>
|
|
export type LetterboxSettings = z.infer<typeof letterboxSettingsSchema>
|
|
export type EffectMask = z.infer<typeof effectMaskSchema>
|
|
export type EffectType = z.infer<typeof clipEffectSchema.shape.type>
|
|
export type ClipEffect = z.infer<typeof clipEffectSchema>
|
|
export type TextOverlayStyle = z.infer<typeof textOverlayStyleSchema>
|
|
export type TimelineClip = z.infer<typeof timelineClipSchema>
|
|
export type Timeline = z.infer<typeof timelineSchema>
|
|
export type AssetBins = z.infer<typeof assetBinsSchema>
|
|
export type ProjectV1 = z.infer<typeof projectV1Schema>
|
|
export type ProjectV2 = z.infer<typeof projectV2Schema>
|
|
export type Project = ProjectV2
|
|
export type ViewType = z.infer<typeof viewTypeSchema>
|
|
export type ProjectTab = z.infer<typeof projectTabSchema>
|
|
|
|
export function createAssetBinId(): string {
|
|
return `bin-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
|
|
}
|
|
|
|
export function createDefaultTimeline(name: string = 'Timeline 1'): Timeline {
|
|
return {
|
|
id: `timeline-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
name,
|
|
createdAt: Date.now(),
|
|
tracks: DEFAULT_TRACKS.map(track => ({ ...track })),
|
|
clips: [],
|
|
subtitles: [],
|
|
}
|
|
}
|
|
|
|
function normalizeLegacyBinName(bin: string | undefined): string | null {
|
|
const normalized = bin?.trim()
|
|
return normalized ? normalized : null
|
|
}
|
|
|
|
function migrateProjectV1ToV2(project: ProjectV1): ProjectV2 {
|
|
const bins = Object.fromEntries(
|
|
Array.from(new Set(project.assets.flatMap(asset => {
|
|
const binName = normalizeLegacyBinName(asset.bin)
|
|
return binName ? [binName] : []
|
|
}))).map(binName => [createAssetBinId(), binName]),
|
|
)
|
|
const binNameToId = new Map<string, string>(
|
|
Object.entries(bins).map(([binId, binName]) => [binName, binId]),
|
|
)
|
|
|
|
return projectV2Schema.parse({
|
|
...project,
|
|
version: 2,
|
|
bins,
|
|
assets: project.assets.map(({ bin, ...asset }) => {
|
|
const binName = normalizeLegacyBinName(bin)
|
|
const binId = binName ? binNameToId.get(binName) : undefined
|
|
return binId ? { ...asset, binId } : asset
|
|
}),
|
|
timelines: project.timelines ?? [createDefaultTimeline('Timeline 1')],
|
|
activeTimelineId: project.timelines ? project.activeTimelineId : undefined,
|
|
})
|
|
}
|
|
|
|
export function migrateProjectData(projectData: unknown): { project: Project; migrated: boolean } {
|
|
const { version } = projectVersionSchema.parse(projectData)
|
|
|
|
if (version === undefined) {
|
|
return {
|
|
project: migrateProjectV1ToV2(projectV1Schema.parse(projectData)),
|
|
migrated: true,
|
|
}
|
|
}
|
|
|
|
if (version === 2) {
|
|
return {
|
|
project: projectV2Schema.parse(projectData),
|
|
migrated: false,
|
|
}
|
|
}
|
|
|
|
throw new Error(`Unsupported project version: ${String(version)}`)
|
|
}
|
|
|
|
export function normalizeProject(projectData: unknown): Project {
|
|
return migrateProjectData(projectData).project
|
|
}
|