Skip to content
Open
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
124 changes: 124 additions & 0 deletions packages/app/src/components/core/timeline/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use client'

import { useState } from 'react'
import { useTimeline } from '@aitube/timeline'
import { ClapSegmentCategory } from '@aitube/clap'
import { Plus, ListPlus } from 'lucide-react'

import { cn } from '@/lib/utils'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'

const TRACK_CATEGORIES: { value: ClapSegmentCategory; label: string }[] = [
{ value: ClapSegmentCategory.VIDEO, label: 'Video' },
{ value: ClapSegmentCategory.IMAGE, label: 'Image' },
{ value: ClapSegmentCategory.DIALOGUE, label: 'Dialogue' },
{ value: ClapSegmentCategory.MUSIC, label: 'Music' },
{ value: ClapSegmentCategory.SOUND, label: 'Sound' },
{ value: ClapSegmentCategory.CAMERA, label: 'Camera' },
{ value: ClapSegmentCategory.ACTION, label: 'Action' },
{ value: ClapSegmentCategory.CHARACTER, label: 'Character' },
{ value: ClapSegmentCategory.LOCATION, label: 'Location' },
{ value: ClapSegmentCategory.LIGHTING, label: 'Lighting' },
{ value: ClapSegmentCategory.STYLE, label: 'Style' },
]

export function TimelineToolbar() {
const [selectedCategory, setSelectedCategory] = useState<ClapSegmentCategory>(
ClapSegmentCategory.VIDEO
)
const [selectedTrack, setSelectedTrack] = useState<string>('')

const tracks = useTimeline((s) => s.tracks)
const createTrack = useTimeline((s) => s.createTrack)
const createClip = useTimeline((s) => s.createClip)
const cursorTimestampAtInMs = useTimeline((s) => s.cursorTimestampAtInMs)

const handleCreateTrack = () => {
const newTrackId = createTrack(selectedCategory)
setSelectedTrack(String(newTrackId))
}

const handleCreateClip = async () => {
const trackNumber = selectedTrack ? parseInt(selectedTrack, 10) : -1
if (trackNumber < 0 || !tracks[trackNumber]) return

await createClip({
track: trackNumber,
startTimeInMs: cursorTimestampAtInMs || 0,
category: selectedCategory,
})
}

return (
<div
className={cn(
'flex flex-row items-center gap-2',
'h-7 px-2',
'text-xs text-white/70'
)}
>
<Select
value={selectedCategory}
onValueChange={(val) => setSelectedCategory(val as ClapSegmentCategory)}
>
<SelectTrigger className="h-5 w-[110px] border-white/20 bg-transparent text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{TRACK_CATEGORIES.map(({ value, label }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>

<button
onClick={handleCreateTrack}
className={cn(
'flex items-center gap-1 rounded px-1.5 py-0.5',
'bg-white/10 hover:bg-white/20',
'transition-colors'
)}
title="Create a new track with the selected category"
>
<ListPlus className="h-3.5 w-3.5" />
<span>Track</span>
</button>

<Select value={selectedTrack} onValueChange={setSelectedTrack}>
<SelectTrigger className="h-5 w-[130px] border-white/20 bg-transparent text-xs">
<SelectValue placeholder="Select track" />
</SelectTrigger>
<SelectContent>
{tracks.map((track) => (
<SelectItem key={track.id} value={String(track.id)}>
Track {track.id} ({track.name})
</SelectItem>
))}
</SelectContent>
</Select>

<button
onClick={handleCreateClip}
disabled={!selectedTrack}
className={cn(
'flex items-center gap-1 rounded px-1.5 py-0.5',
'bg-white/10 hover:bg-white/20',
'transition-colors',
'disabled:cursor-not-allowed disabled:opacity-40'
)}
title="Create a new clip on the selected track at the cursor position"
>
<Plus className="h-3.5 w-3.5" />
<span>Clip</span>
</button>
</div>
)
}
15 changes: 8 additions & 7 deletions packages/app/src/components/core/timeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ClapTimeline, useTimeline, SegmentResolver } from '@aitube/timeline'
import { useMonitor } from '@/services/monitor/useMonitor'
import { useResolver } from '@/services/resolver/useResolver'
import { useUI } from '@/services/ui'
import { cn } from '@/lib/utils'
import { TimelineToolbar } from './TimelineToolbar'

export function Timeline(
{
Expand Down Expand Up @@ -56,13 +58,12 @@ export function Timeline(
togglePlayback,
])

if (className) {
return (
<div className={className}>
return (
<div className={cn('flex h-full w-full flex-col', className)}>
<TimelineToolbar />
<div className="flex-1 overflow-hidden">
<ClapTimeline showFPS={false} />
</div>
)
}

return <ClapTimeline showFPS={false} className={className} />
</div>
)
}
194 changes: 193 additions & 1 deletion packages/timeline/src/hooks/useTimeline.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { create } from "zustand"
import * as THREE from "three"
import type { ThreeEvent } from "@react-three/fiber"
import { ClapProject, ClapSegment, ClapSegmentCategory, isValidNumber, newClap, serializeClap, ClapTracks, ClapEntity, ClapMeta } from "@aitube/clap"
import { ClapProject, ClapSegment, ClapSegmentCategory, ClapOutputType, isValidNumber, newClap, newSegment, serializeClap, ClapTracks, ClapEntity, ClapMeta } from "@aitube/clap"

import { TimelineSegment, SegmentEditionStatus, SegmentVisibility, TimelineStore, SegmentArea, SegmentPointerEvent, SegmentEventCallbackHandler, Invalidate } from "@/types/timeline"
import { getDefaultProjectState, getDefaultState } from "@/utils/getDefaultState"
Expand Down Expand Up @@ -1206,6 +1206,198 @@ export const useTimeline = create<TimelineStore>((set, get) => ({
})
})
},
createTrack: (category: ClapSegmentCategory): number => {
const {
width,
height,
tracks,
cellWidth,
defaultSegmentDurationInSteps,
durationInMsPerStep,
durationInMs,
defaultPreviewHeight,
defaultCellHeight,
} = get()

const isPreview =
category === ClapSegmentCategory.IMAGE ||
category === ClapSegmentCategory.VIDEO

const newTrackId = tracks.length

const updatedTracks = [
...tracks,
{
id: newTrackId,
name: `${category}`,
isPreview,
height: isPreview ? defaultPreviewHeight : defaultCellHeight,
hue: 0,
occupied: true,
visible: true,
},
]

set({
...computeContentSizeMetrics({
width,
height,
tracks: updatedTracks,
cellWidth,
defaultSegmentDurationInSteps,
durationInMsPerStep,
durationInMs,
}),
})

return newTrackId
},

createClip: async ({
track,
startTimeInMs,
endTimeInMs,
category,
prompt = '',
}: {
track: number
startTimeInMs: number
endTimeInMs?: number
category?: ClapSegmentCategory
prompt?: string
}): Promise<TimelineSegment> => {
const {
tracks,
durationInMsPerStep,
defaultSegmentDurationInSteps,
addSegment,
} = get()

const trackInfo = tracks[track]
const trackCategory = category || (
trackInfo
? ClapSegmentCategory[trackInfo.name as keyof typeof ClapSegmentCategory] || ClapSegmentCategory.GENERIC
: ClapSegmentCategory.GENERIC
)

const defaultDurationInMs = defaultSegmentDurationInSteps * durationInMsPerStep
const segmentEndTimeInMs = endTimeInMs || (startTimeInMs + defaultDurationInMs)

const outputType =
trackCategory === ClapSegmentCategory.VIDEO ? ClapOutputType.VIDEO
: trackCategory === ClapSegmentCategory.IMAGE ? ClapOutputType.IMAGE
: trackCategory === ClapSegmentCategory.DIALOGUE ? ClapOutputType.AUDIO
: trackCategory === ClapSegmentCategory.MUSIC ? ClapOutputType.AUDIO
: trackCategory === ClapSegmentCategory.SOUND ? ClapOutputType.AUDIO
: ClapOutputType.TEXT

// Check for time-overlap collisions on the requested track
const { segments, findFreeTrack: findFree } = get()
const hasCollision = segments.some(
(s) =>
s.track === track &&
!(s.endTimeInMs <= startTimeInMs || s.startTimeInMs >= segmentEndTimeInMs)
)

const resolvedTrack = hasCollision
? findFree({ startTimeInMs, endTimeInMs: segmentEndTimeInMs })
: track

const clapSegment = newSegment({
track: resolvedTrack,
startTimeInMs,
endTimeInMs: segmentEndTimeInMs,
category: trackCategory,
prompt,
outputType,
})

const segment = await clapSegmentToTimelineSegment(clapSegment)

await addSegment({ segment, startTimeInMs, track: resolvedTrack })

Comment on lines +1276 to +1318
return segment
},

moveSegmentToTrack: (segment: TimelineSegment, targetTrack: number): boolean => {
const {
width,
height,
tracks,
segments,
cellWidth,
defaultSegmentDurationInSteps,
durationInMsPerStep,
durationInMs,
invalidate,
allSegmentsChanged: prevAllChanged,
atLeastOneSegmentChanged: prevOneChanged,
} = get()

const targetTrackInfo = tracks[targetTrack]
if (!targetTrackInfo) { return false }

// Allow moves to empty/misc tracks or same-category tracks
const targetName = targetTrackInfo.name
const isCompatible =
!targetTrackInfo.occupied ||
targetName === '(empty)' ||
targetName === '(misc)' ||
targetName === segment.category

if (!isCompatible) { return false }

// Check for time-overlap collisions on the target track
const hasCollision = segments.some(
(s) =>
s.id !== segment.id &&
s.track === targetTrack &&
!(s.endTimeInMs <= segment.startTimeInMs || s.startTimeInMs >= segment.endTimeInMs)
)

if (hasCollision) { return false }

// Immutable update: create new segment with updated track
const updatedSegments = segments.map((s) =>
s.id === segment.id ? { ...s, track: targetTrack } : s
)

// Immutable update: create new tracks array if target was empty
const updatedTracks = !targetTrackInfo.occupied
? tracks.map((t, i) => {
if (i !== targetTrack) { return t }
const isPreview =
segment.category === ClapSegmentCategory.IMAGE ||
segment.category === ClapSegmentCategory.VIDEO
return {
...t,
name: `${segment.category}`,
occupied: true,
isPreview,
}
})
: [...tracks]

set({
segments: updatedSegments,
allSegmentsChanged: prevAllChanged + 1,
atLeastOneSegmentChanged: prevOneChanged + 1,
...computeContentSizeMetrics({
width,
height,
tracks: updatedTracks,
cellWidth,
defaultSegmentDurationInSteps,
durationInMsPerStep,
durationInMs,
}),
})

invalidate()

return true
},

deleteSegments: (ids: string[]): void => {
const {
segments: previousSegments,
Expand Down
25 changes: 24 additions & 1 deletion packages/timeline/src/types/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as THREE from "three"
import type { ThreeEvent } from "@react-three/fiber"
import { ClapEntity, ClapImageRatio, ClapMeta, ClapProject, ClapScene, ClapSegment, ClapTracks } from "@aitube/clap"
import { ClapEntity, ClapImageRatio, ClapMeta, ClapProject, ClapScene, ClapSegment, ClapSegmentCategory, ClapTracks } from "@aitube/clap"

import { ClapSegmentColorScheme, ClapTimelineTheme } from "./theme"
import { TimelineControlsImpl } from "@/components/controls/types"
Expand Down Expand Up @@ -397,6 +397,29 @@ export type TimelineStoreModifiers = {
*/
deleteSegments: (ids: string[]) => void

/**
* Create a new track with a given category
* @returns the track number of the newly created track
*/
createTrack: (category: ClapSegmentCategory) => number

/**
* Create a new clip/segment on a track at a given time position
*/
createClip: (params: {
track: number
startTimeInMs: number
endTimeInMs?: number
category?: ClapSegmentCategory
prompt?: string
}) => Promise<TimelineSegment>

/**
* Move a segment to a different track, validating category compatibility
* @returns true if the move succeeded
*/
moveSegmentToTrack: (segment: TimelineSegment, targetTrack: number) => boolean

addEntities: (entities: ClapEntity[]) => Promise<void>
updateEntities: (entities: ClapEntity[]) => Promise<void>
deleteEntities: (entities: (ClapEntity|string)[]) => Promise<void>
Expand Down