Spaces:
Running
Running
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 { TimelineSegment, SegmentEditionStatus, SegmentVisibility, TimelineStore, SegmentArea, SegmentPointerEvent, SegmentEventCallbackHandler, Invalidate } from "@/types/timeline" | |
import { getDefaultProjectState, getDefaultState } from "@/utils/getDefaultState" | |
import { DEFAULT_NB_TRACKS, leftBarTrackScaleWidth } from "@/constants" | |
import { findFreeTrack, removeFinalVideosAndConvertToTimelineSegments, clapSegmentToTimelineSegment, timelineSegmentToClapSegment } from "@/utils" | |
import { ClapTimelineTheme, SegmentResolver } from "@/types" | |
import { TimelineControlsImpl } from "@/components/controls/types" | |
import { TimelineCameraImpl } from "@/components/camera/types" | |
import { IsPlaying, JumpAt, TimelineCursorImpl, TogglePlayback } from "@/components/timeline/types" | |
import { computeContentSizeMetrics } from "@/compute/computeContentSizeMetrics" | |
import { topBarTimeScaleHeight } from "@/constants/themes" | |
export const useTimeline = create<TimelineStore>((set, get) => ({ | |
...getDefaultState(), | |
setCanvas: (canvas?: HTMLCanvasElement) => { | |
set({ canvas }) | |
}, | |
clear: () => { | |
// this re-initialize everything that is related to the current .clap project | |
set({ | |
...getDefaultProjectState() | |
}) | |
}, | |
setClap: async (clap?: ClapProject) => { | |
const { clear } = get() | |
clear() | |
if (!clap) { | |
console.log(`useTimeline: no clap to show`) | |
return | |
} | |
set({ isLoading: true }) | |
// actually you know what.. let's drop the concept of final video for the moment | |
// in Clapper and the timeline | |
// const finalVideo = await getFinalVideo(clap) | |
const finalVideo = undefined | |
// we remove the big/long video | |
const segments = await removeFinalVideosAndConvertToTimelineSegments(clap) | |
const { | |
defaultCellHeight, | |
durationInMsPerStep, | |
cellWidth, | |
} = get() | |
const meta = clap.meta | |
// TODO: many of those checks about average duration, nb of tracks, collisions... | |
// should be done by the Clap parser and/or serializer | |
// send a demand to Julian (@flngr) to get it fixed! | |
let idCollisionDetector = new Set<string>() | |
let tracks: ClapTracks = [] | |
let defaultSegmentDurationInSteps = get().defaultSegmentDurationInSteps | |
for (const s of segments) { | |
if (s.category === ClapSegmentCategory.CAMERA) { | |
const durationInSteps = ( | |
(s.endTimeInMs - s.startTimeInMs) / durationInMsPerStep | |
) | |
// TODO: we should do this row by row | |
// and look at the most recurring duration, | |
// using a table | |
defaultSegmentDurationInSteps = durationInSteps | |
break | |
} | |
} | |
const defaultSegmentLengthInPixels = cellWidth * defaultSegmentDurationInSteps | |
// TODO: compute the exact image ratio instead of using the media orientation, | |
// since it might not match the actual assets | |
const defaultImageRatio = clap ? ( | |
(clap.meta.width || 896) / (clap.meta.height || 512) | |
) : (896 / 512) | |
// also storyboard images and videos might have different sizes / ratios | |
const defaultPreviewHeight = Math.round( | |
defaultSegmentLengthInPixels / defaultImageRatio | |
) | |
const lineNumberToMentionedSegments: Record<number, TimelineSegment[]> = {} | |
for (const segment of segments) { | |
// TODO: move this idCollision detector into the state, | |
// so that we can use it later? | |
if (idCollisionDetector.has(segment.id)) { | |
console.log(`collision detected! there is already a segment with id ${segment.id}`) | |
continue | |
} | |
// -------- | |
const isSegmentDirectlyMentionedInTheScript = segment.category === ClapSegmentCategory.DIALOGUE || segment.category === ClapSegmentCategory.ACTION | |
if (isSegmentDirectlyMentionedInTheScript) { | |
for (let i = segment.startTimeInLines; i <= segment.endTimeInLines; i++) { | |
// we only add the segment if it is not already in the map | |
let existingArray: TimelineSegment[] = lineNumberToMentionedSegments[i] || [] | |
if (!existingArray.find(s => s.id === segment.id)) { | |
existingArray.push(segment) | |
} | |
lineNumberToMentionedSegments[i] = existingArray | |
} | |
} | |
idCollisionDetector.add(segment.id) | |
if (!tracks[segment.track]) { | |
const isPreview = | |
segment.category === ClapSegmentCategory.IMAGE || | |
segment.category === ClapSegmentCategory.VIDEO | |
tracks[segment.track] = { | |
id: segment.track, | |
// name: `Track ${s.track}`, | |
name: `${segment.category}`, | |
isPreview, | |
height: | |
isPreview | |
? defaultPreviewHeight | |
: defaultCellHeight, | |
hue: 0, | |
occupied: true, | |
visible: true, | |
} | |
} else { | |
const track = tracks[segment.track] | |
const categories: string[] = track.name.split(",").map((x: string) => x.trim()) | |
if (!categories.includes(segment.category)) { | |
tracks[segment.track].name = "(misc)" | |
/* | |
if (categories.length < 2) { | |
categories.push(s.category) | |
track.name = categories.join(", ") | |
} else if (!track.name.includes("..")) { | |
track.name = track.name + ".." | |
} | |
*/ | |
} | |
} | |
} | |
// ---------- FILL-IN THE TRACKS --------------- | |
for (let id = 0; id < DEFAULT_NB_TRACKS; id++) { | |
if (!tracks[id]) { | |
tracks[id] = { | |
id, | |
name: `(empty)`, | |
isPreview: false, | |
height: defaultCellHeight, | |
hue: 0, | |
occupied: false, // <-- setting this to false is the important part | |
visible: true, | |
} | |
} | |
} | |
let durationInMs = clap.meta.durationInMs | |
let totalNumberOfLines = clap.meta.storyPrompt.split('\n').length | |
// console.log("totalNumberOfLines = " + totalNumberOfLines) | |
// ---------- REPAIR THE LINE-2-SEGMENT DICTIONARY --------------- | |
let previousValue: TimelineSegment[] = [] | |
// we aren't finished yet: the lineNumberToMentionedSegments will be missing some entries | |
for (let i = 1; i <= totalNumberOfLines; i++) { | |
if (!Array.isArray(lineNumberToMentionedSegments[i])) { | |
lineNumberToMentionedSegments[i] = previousValue | |
} else { | |
previousValue = lineNumberToMentionedSegments[i] | |
} | |
} | |
set({ | |
...meta, | |
scenes: clap.scenes, | |
segments, | |
entities: clap.entities, | |
entityIndex: clap.entityIndex, | |
entitiesChanged: 0, | |
loadedSegments: [], | |
visibleSegments: [], | |
atLeastOneSegmentChanged: 1, | |
allSegmentsChanged: 1, | |
durationInMs, | |
lineNumberToMentionedSegments, | |
isLoading: false, | |
finalVideo, | |
...computeContentSizeMetrics({ | |
width: meta.width, | |
height: meta.height, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
}) | |
}) | |
// one more thing: we need to call this, | |
// as this will trigger various stuff in the parent | |
get().jumpAt(0) | |
}, | |
getClap: async (): Promise<ClapProject> => { | |
const { | |
getClapMeta, | |
entities, | |
scenes, | |
segments | |
} = get() | |
const clap = newClap({ | |
meta: getClapMeta(), | |
entities: [...entities], | |
scenes: [...scenes], | |
segments: segments.map(ts => timelineSegmentToClapSegment(ts)) | |
}) | |
return clap | |
}, | |
getClapMeta: (): ClapMeta => { | |
const { | |
id, | |
title, | |
description, | |
synopsis, | |
licence, | |
bpm, | |
frameRate, | |
tags, | |
thumbnailUrl, | |
imageRatio, | |
durationInMs, | |
width, | |
height, | |
imagePrompt, | |
systemPrompt, | |
storyPrompt, | |
isLoop, | |
isInteractive, | |
} = get() | |
return { | |
id, | |
title, | |
description, | |
synopsis, | |
licence, | |
bpm, | |
frameRate, | |
tags, | |
thumbnailUrl, | |
imageRatio, | |
durationInMs, | |
width, | |
height, | |
imagePrompt, | |
systemPrompt, | |
storyPrompt, | |
isLoop, | |
isInteractive, | |
} | |
}, | |
setHorizontalZoomLevel: (newHorizontalZoomLevel: number) => { | |
const { | |
minHorizontalZoomLevel, | |
maxHorizontalZoomLevel, | |
width, | |
height, | |
tracks, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
cellWidth: previousCellWidth, | |
durationInMs, | |
} = get() | |
const cellWidth = Math.min(maxHorizontalZoomLevel, Math.max(minHorizontalZoomLevel, newHorizontalZoomLevel)) | |
// nothing changed | |
if (Math.round(cellWidth) === Math.round(previousCellWidth)) { return } | |
const resizeStartedAt = performance.now() | |
const isResizing = true | |
set({ | |
resizeStartedAt, | |
isResizing, | |
...computeContentSizeMetrics({ | |
width, | |
height, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
}) | |
}) | |
}, | |
setSegments: (segments: TimelineSegment[] = []) => { | |
set({ segments, loadedSegments: [] }) | |
}, | |
setLoadedSegments: (loadedSegments: TimelineSegment[] = []) => { set({ loadedSegments }) }, | |
setVisibleSegments: (visibleSegments: TimelineSegment[] = []) => { set({ visibleSegments }) }, | |
getCellHeight: (trackNumber?: number): number => { | |
const { defaultCellHeight, tracks } = get() | |
const track = tracks[trackNumber!] | |
return track?.height || defaultCellHeight | |
}, | |
getVerticalCellPosition: (start: number, end: number): number => { | |
const { defaultCellHeight, tracks } = get() | |
let height = 0 | |
for (let i = start; i < end; i++) { | |
const track = tracks[i!] | |
height += track?.height || defaultCellHeight | |
} | |
return height | |
}, | |
setHoveredSegment: ({ | |
segment, | |
area, | |
}: { | |
segment?: TimelineSegment | |
area?: SegmentArea | |
} = {}) => { | |
const { | |
invalidate, | |
hoveredSegment: previousHoveredSegment, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: previousAllSegmentsChanged, | |
} = get() | |
// note: we do all of this in order to avoid useless state updates | |
if (segment && area) { | |
if (previousHoveredSegment) { | |
if (previousHoveredSegment.id === segment.id) { | |
// nothing to do | |
return | |
} else { | |
previousHoveredSegment.isHovered = false | |
segment.isHoveredOnLeftHandle = false | |
segment.isHoveredOnRightHandle = false | |
segment.isHoveredOnBody = false | |
} | |
} else { | |
segment.isHovered = true | |
segment.isHoveredOnLeftHandle = area === SegmentArea.LEFT | |
segment.isHoveredOnRightHandle = area === SegmentArea.RIGHT | |
segment.isHoveredOnBody = area === SegmentArea.MIDDLE | |
set({ | |
hoveredSegment: segment, | |
allSegmentsChanged: 1 + previousAllSegmentsChanged, | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged | |
}) | |
} | |
} else { | |
if (previousHoveredSegment) { | |
previousHoveredSegment.isHovered = false | |
previousHoveredSegment.isHoveredOnLeftHandle = false | |
previousHoveredSegment.isHoveredOnRightHandle = false | |
previousHoveredSegment.isHoveredOnBody = false | |
set({ | |
hoveredSegment: undefined, | |
allSegmentsChanged: 1 + previousAllSegmentsChanged, | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged | |
}) | |
} else { | |
// nothing to do | |
} | |
} | |
// since we are un frameloop="demand" mode, we need to manual invalidate the scene | |
invalidate() | |
}, | |
setEditedSegment: ({ | |
segment, | |
status = SegmentEditionStatus.EDITING | |
}: { | |
segment?: TimelineSegment | |
status?: SegmentEditionStatus | |
} = { | |
status: SegmentEditionStatus.EDITING | |
}) => { | |
const { | |
invalidate, | |
editedSegment: previousEditedSegment, | |
allSegmentsChanged: previousAllSegmentsChanged, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged | |
} = get() | |
// since we are un frameloop="demand" mode, we need to manual invalidate the scene | |
invalidate() | |
// note: we do all of this in order to avoid useless state updates | |
if (segment) { | |
if (previousEditedSegment) { | |
if (previousEditedSegment.id === segment.id) { | |
// nothing to do | |
return | |
} else { | |
previousEditedSegment.editionStatus = SegmentEditionStatus.EDITABLE | |
} | |
} else { | |
segment.editionStatus = status || SegmentEditionStatus.EDITING | |
set({ | |
editedSegment: segment, | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: 1 + previousAllSegmentsChanged | |
}) | |
} | |
} else { | |
if (previousEditedSegment) { | |
previousEditedSegment.editionStatus = SegmentEditionStatus.EDITABLE | |
set({ | |
editedSegment: undefined, | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: 1 + previousAllSegmentsChanged | |
}) | |
} else { | |
// nothing to do | |
} | |
} | |
}, | |
setSelectedSegment: ({ | |
segment, | |
isSelected, | |
onlyOneSelectedAtOnce, | |
}: { | |
segment?: TimelineSegment | |
isSelected?: boolean | |
onlyOneSelectedAtOnce?: boolean | |
} = { | |
}) => { | |
const { | |
invalidate, | |
segments, | |
selectedSegments: previousSelectedSegments, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: previousAllSegmentsChanged | |
} = get() | |
// since we are un frameloop="demand" mode, we need to manual invalidate the scene | |
invalidate() | |
/* | |
console.log(`setSelectedSegment() called with:`, { | |
segment, | |
isSelected, | |
onlyOneSelectedAtOnce, | |
}) | |
*/ | |
let newValue = typeof isSelected !== "boolean" | |
? (typeof segment?.isSelected === "boolean" ? (!segment.isSelected) : false) | |
: isSelected | |
// console.log('`setSelectedSegment(): new value:', newValue) | |
// note: we do all of this in order to avoid useless state updates | |
if (segment) { | |
if (segment.isSelected === newValue) { | |
// console.log('`setSelectedSegment(): nothing to do') | |
// nothing to do | |
return | |
} | |
let newSelectedSegments: TimelineSegment[] = previousSelectedSegments | |
// if needed we clear any other selected item | |
if (onlyOneSelectedAtOnce) { | |
// console.log('`setSelectedSegment(): unselecting all previous segments') | |
segments.forEach(s => { | |
s.isSelected = false | |
}) | |
newSelectedSegments = [] | |
} | |
// console.log('`setSelectedSegment(): assigning new value and propagating changes:', newValue) | |
segment.isSelected = newValue | |
if (newValue) { | |
newSelectedSegments = newSelectedSegments.concat(segment) | |
} else { | |
newSelectedSegments = newSelectedSegments.filter(s => s.id !== segment.id) | |
} | |
set({ | |
selectedSegments: newSelectedSegments, | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: 1 + previousAllSegmentsChanged, | |
}) | |
} else { | |
// console.log('`setSelectedSegment(): mass change requested') | |
segments.forEach(s => { | |
s.isSelected = newValue | |
}) | |
set({ | |
selectedSegments: isSelected ? segments : [], | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: 1 + previousAllSegmentsChanged, | |
}) | |
} | |
}, | |
handleSegmentEvent: ({ | |
eventType, | |
segment, | |
}: { | |
eventType: SegmentPointerEvent | |
segment: TimelineSegment | |
}): SegmentEventCallbackHandler => { | |
function segmentEventCallbackHandler(event: ThreeEvent<PointerEvent> | ThreeEvent<MouseEvent>) { | |
const pointX = event.point.x | |
const offsetX = event.offsetX | |
const offsetY = event.offsetY | |
/* | |
console.log("segmentEventCallbackHandler:" + JSON.stringify({ | |
pointX: Math.round(e.point.x), | |
offsetX: Math.round(e.offsetX), | |
offsetY: Math.round(e.offsetY), | |
isOutOfRange, | |
cursorLeftPosInPx: Math.round(cursorLeftPosInPx), | |
cursorRightPosInPx: Math.round(cursorRightPosInPx), | |
area | |
}, null, 2)) | |
*/ | |
const { cellWidth, containerWidth, durationInMsPerStep, setSelectedSegment, setHoveredSegment, setEditedSegment } = get() | |
const durationInSteps = ( | |
(segment.endTimeInMs - segment.startTimeInMs) / durationInMsPerStep | |
) | |
/* | |
const startTimeInSteps = ( | |
segment.startTimeInMs / durationInMsPerStep | |
) | |
*/ | |
const widthInPx = durationInSteps * cellWidth | |
const segmentWidth = widthInPx | |
const isOutOfRange = offsetX < leftBarTrackScaleWidth || offsetY < topBarTimeScaleHeight | |
const cursorX = pointX + (containerWidth / 2) | |
const cursorTimestampAtInMs = (cursorX / cellWidth) * useTimeline.getState().durationInMsPerStep | |
//console.log("cells.Cell:onClick() e:", e) | |
const wMin = cursorTimestampAtInMs - segment.startTimeInMs | |
const wMax = segment.endTimeInMs - segment.startTimeInMs | |
const cursorLeftPosInRatio = wMin / wMax | |
const cursorLeftPosInPx = cursorLeftPosInRatio * segmentWidth | |
const cursorRightPosInPx = segmentWidth - cursorLeftPosInPx | |
// note: this should be "responsive", with a max width | |
const sideGrabHandleWidth = 9 | |
// let isInLeftArea = cursorLeftPosInRatio < 0.5 | |
// let isInRightArea = cursorLeftPosInRatio > 0.5 | |
const area = | |
(cursorLeftPosInPx < sideGrabHandleWidth) ? SegmentArea.LEFT | |
: (cursorRightPosInPx < sideGrabHandleWidth) ? SegmentArea.RIGHT | |
: SegmentArea.MIDDLE | |
if (isOutOfRange) { | |
event.stopPropagation() | |
return false | |
} | |
if (eventType === SegmentPointerEvent.DOUBLE_CLICK) { | |
setHoveredSegment({ | |
segment, | |
area | |
}) | |
setSelectedSegment({ | |
segment, | |
// we leave it unspecified to create an automated toggle | |
// isSelected: true, | |
onlyOneSelectedAtOnce: true, | |
}) | |
setEditedSegment({ | |
segment, | |
status: SegmentEditionStatus.EDITING | |
}) | |
} else if ( | |
eventType === SegmentPointerEvent.CLICK || | |
eventType === SegmentPointerEvent.DOWN || | |
eventType === SegmentPointerEvent.MOVE | |
) { | |
setHoveredSegment({ | |
segment, | |
area | |
}) | |
if (area === SegmentArea.LEFT) { | |
setEditedSegment({ | |
segment, | |
status: SegmentEditionStatus.RESIZE_START | |
}) | |
} else if (area === SegmentArea.RIGHT) { | |
setEditedSegment({ | |
segment, | |
status: SegmentEditionStatus.RESIZE_END | |
}) | |
} else if (area === SegmentArea.MIDDLE) { | |
setEditedSegment({ | |
segment, | |
status: SegmentEditionStatus.DRAGGING | |
}) | |
} | |
} else if (eventType === SegmentPointerEvent.UP) { | |
setHoveredSegment({ | |
segment: undefined, | |
area | |
}) | |
setEditedSegment({ | |
segment: undefined, | |
}) | |
} | |
event.stopPropagation() | |
return false | |
} | |
return segmentEventCallbackHandler | |
}, | |
trackSilentChangeInSegment: (segmentId: string) => { | |
const { silentChangesInSegment, atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged } = get() | |
set({ | |
silentChangesInSegment: Object.assign(silentChangesInSegment, { | |
[segmentId]: 1 + (silentChangesInSegment[segmentId] || 0) | |
}), | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged, | |
}) | |
}, | |
trackSilentChangeInSegments: (segmentIds: string[]) => { | |
const { silentChangesInSegment, atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged } = get() | |
for (const id of segmentIds) { | |
silentChangesInSegment[id] = 1 + (silentChangesInSegment[id] || 0) | |
} | |
set({ | |
silentChangesInSegment, | |
atLeastOneSegmentChanged: 1 + previousAtLeastOneSegmentChanged, | |
}) | |
}, | |
setTimelineTheme: (theme: ClapTimelineTheme) => { | |
set({ theme }) | |
}, | |
setTimelineCamera: (timelineCamera?: TimelineCameraImpl) => { | |
set({ timelineCamera }) | |
}, | |
setTimelineControls: (timelineControls?: TimelineControlsImpl) => { | |
set({ timelineControls }) | |
}, | |
setTopBarTimeScale: (topBarTimeScale?: THREE.Group<THREE.Object3DEventMap>) => { | |
set({ topBarTimeScale }) | |
}, | |
setLeftBarTrackScale: (leftBarTrackScale?: THREE.Group<THREE.Object3DEventMap>) => { | |
set({ leftBarTrackScale }) | |
}, | |
// used when we move the full-length scroller | |
setScrollX: (scrollX: number) => { | |
set({ scrollX }) | |
}, | |
handleMouseWheel: ({ deltaX, deltaY }: { deltaX: number, deltaY: number }) => { | |
const { scrollX, scrollY } = get() | |
// TODO: compute the limits here, to avoid doing re-renderings for nothing | |
set({ | |
scrollX: scrollX + deltaX, | |
scrollY: scrollY - deltaY, | |
}) | |
}, | |
toggleTrackVisibility: (trackId: number) => { | |
const { | |
width, | |
height, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
} = get() | |
set({ | |
...computeContentSizeMetrics({ | |
width, | |
height, | |
tracks: tracks.map((t: any) => ( | |
t.id === trackId | |
? { ...t, visible: !t.visible } | |
: t | |
)), | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
}) | |
}) | |
}, | |
setContainerSize: ({ width, height }: { width: number; height: number }) => { | |
const { containerWidth: previousWidth, containerHeight: previousHeight } = get() | |
const changed = | |
(Math.round(previousWidth) !== Math.round(height)) | |
|| (Math.round(previousHeight) !== Math.round(height)) | |
if (!changed) { return } | |
set({ | |
containerWidth: width, | |
containerHeight: height, | |
resizeStartedAt: performance.now(), | |
isResizing: true, | |
/* | |
changing the *container* size has absolutely no impact on the content | |
...computeContentSizeMetrics({ | |
clap, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps | |
}) | |
*/ | |
}) | |
}, | |
setTimelineCursor: (timelineCursor?: TimelineCursorImpl) => { | |
set({ timelineCursor }) | |
}, | |
setIsDraggingCursor: (isDraggingCursor: boolean) => { | |
set({ isDraggingCursor }) | |
}, | |
setCursorTimestampAtInMs: (cursorTimestampAtInMs: number = 0) => { | |
const { invalidate, cursorTimestampAtInMs: previousCursorTimestampAtInMs } = get() | |
if (cursorTimestampAtInMs !== previousCursorTimestampAtInMs) { | |
set({ cursorTimestampAtInMs }) | |
invalidate() | |
} | |
}, | |
setJumpAt: (jumpAt: JumpAt) => { | |
set({ jumpAt }) | |
}, | |
setIsPlaying: (isPlaying: IsPlaying) => { | |
set({ isPlaying }) | |
}, | |
setTogglePlayback: (togglePlayback: TogglePlayback) => { | |
set({ togglePlayback }) | |
}, | |
// this function has an issue, it saves stuff as .txt, which is bad | |
saveClapAs: async ({ | |
embedded, | |
saveToFilePath, | |
showTargetDirPopup = false, | |
// some extra text to append to the file name | |
extraLabel = "" | |
}: { | |
// if embedded is true, the file will be larger, as all the content, | |
// image, video, audio.. | |
// will be embedded into it (except the last big video) | |
embedded?: boolean | |
saveToFilePath?: string | |
// note: the native select picker doesn't work in all browsers (eg. not in Firefox) | |
// but it's not an issue, in our case we can save using Node/Electron + the cloud | |
showTargetDirPopup?: boolean | |
// some extra text to append to the file name | |
extraLabel?: string | |
} = {}) => { | |
const { getClap } = get() | |
const clap = await getClap() | |
const blob = await serializeClap(clap) | |
// Create an object URL for the compressed clap blob | |
const objectUrl = URL.createObjectURL(blob); | |
// Create an anchor element and force browser download | |
const anchor = document.createElement("a"); | |
anchor.href = objectUrl; | |
anchor.download = saveToFilePath || `${clap.meta.title}${extraLabel}.clap`; | |
document.body.appendChild(anchor); // Append to the body (could be removed once clicked) | |
anchor.click(); // Trigger the download | |
// Cleanup: revoke the object URL and remove the anchor element | |
URL.revokeObjectURL(objectUrl); | |
document.body.removeChild(anchor); | |
return objectUrl.length | |
}, | |
setSegmentResolver: (segmentResolver: SegmentResolver) => { | |
set({ segmentResolver }) | |
}, | |
resolveSegment: async (segment: TimelineSegment): Promise<TimelineSegment> => { | |
const { segmentResolver, fitSegmentToAssetDuration } = get() | |
if (!segmentResolver) { return segment } | |
segment = await segmentResolver(segment) | |
// after a segment has ben resolved, it is possible that the size | |
// of its asset changed (eg. a dialogue line longer than the segment's length) | |
// | |
// there are multiple ways to solve this, one approach could be to | |
// just add some more B-roll (more shots) | |
// | |
// or we can also extend it, which is the current simple solution | |
// | |
// for the other categories, such as MUSIC or SOUND, | |
// we assume it is okay if they are too short or too long, | |
// and that we can crop them etc | |
// | |
// note that video clips are also concerned: we want them to perfectly fit | |
if (segment.category === ClapSegmentCategory.DIALOGUE) { | |
await fitSegmentToAssetDuration(segment) | |
} else if (segment.category === ClapSegmentCategory.VIDEO) { | |
await fitSegmentToAssetDuration(segment) | |
} | |
return segment | |
}, | |
addSegments: async ({ | |
segments = [], | |
startTimeInMs, | |
track | |
}: { | |
segments?: TimelineSegment[] | |
startTimeInMs?: number | |
track?: number | |
}): Promise<void> => { | |
if (segments?.length) { | |
const { addSegment } = get() | |
for (const segment of segments) { | |
await addSegment({ | |
segment, | |
startTimeInMs, | |
track | |
}) | |
} | |
} | |
}, | |
assignTrack: async ({ | |
segment, | |
track, | |
triggerChange, | |
}: { | |
segment: ClapSegment | |
track: number | |
triggerChange?: boolean | |
}): Promise<void> => { | |
const { | |
width, | |
height, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
tracks, | |
defaultPreviewHeight, | |
defaultCellHeight, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: previousAllSegmentsChanged, | |
} = get() | |
segment.track = track | |
let nbTracks = tracks.length | |
// add the track if it is missing | |
if (!tracks[segment.track]) { | |
const isPreview = | |
segment.category === ClapSegmentCategory.IMAGE || | |
segment.category === ClapSegmentCategory.VIDEO | |
tracks[segment.track] = { | |
id: segment.track, | |
// name: `Track ${s.track}`, | |
name: `${segment.category}`, | |
isPreview, | |
height: | |
isPreview | |
? defaultPreviewHeight | |
: defaultCellHeight, | |
hue: 0, | |
occupied: true, | |
visible: true, | |
} | |
} | |
if (triggerChange) { | |
set({ | |
allSegmentsChanged: previousAllSegmentsChanged + 1, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged + 1, | |
...computeContentSizeMetrics({ | |
width, | |
height, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
}), | |
}) | |
} | |
}, | |
addSegment: async ({ | |
segment, | |
startTimeInMs: requestedStartTimeInMs, | |
track: requestedTrack | |
}: { | |
segment: TimelineSegment | |
startTimeInMs?: number | |
track?: number | |
}): Promise<void> => { | |
// adding a segment is a bit complicated, lot of stuff might have to be updated | |
const { | |
width, | |
height, | |
findFreeTrack, | |
cellWidth, | |
tracks, | |
segments: previousSegments, | |
allSegmentsChanged: previousAllSegmentsChanged, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged, | |
durationInMs: previousDurationInMs, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
defaultPreviewHeight, | |
defaultCellHeight, | |
assignTrack, | |
} = get() | |
// note: the requestedTrack might not be empty | |
const segmentDuration = segment.endTimeInMs - segment.startTimeInMs | |
const startTimeInMs = isValidNumber(requestedStartTimeInMs) ? requestedStartTimeInMs! : segment.startTimeInMs | |
const endTimeInMs = startTimeInMs + segmentDuration | |
// for now let's do something simple: to always search for an available track | |
const availableTrack = isValidNumber(requestedTrack) ? requestedTrack! : findFreeTrack({ | |
startTimeInMs, | |
endTimeInMs | |
}) | |
/* | |
console.log("availableTrack:", { | |
requestedStartTimeInMs, | |
segmentDuration, | |
availableTrack, | |
requestedTrack, | |
startTimeInMs, | |
endTimeInMs | |
}) | |
*/ | |
// we just make sure to sanitize it before adding it | |
segment = await clapSegmentToTimelineSegment(segment) | |
// also, we assume that we are adding a segment in a place where it's visible | |
// (if we are wrong don't worry, our visibility detector will fix it anyway) | |
segment.visibility = SegmentVisibility.VISIBLE | |
assignTrack({ | |
segment, | |
track: availableTrack, | |
// we don't want to trigger a state change just yet | |
triggerChange: false, | |
}) | |
// we assume that the provided segment is valid, with a unique UUID | |
// then we need to update everything | |
// ok so, I'm not a big fan of doing this, | |
// officially the order doesn't matter in the previousSegments array | |
// this means we don't have FOR LOOPs with a BREAK etc | |
// still, I think we can improve our performance one day by storing them | |
// on a temporally sorted tree | |
const segments = previousSegments.concat(segment) | |
const durationInMs = | |
segment.endTimeInMs > previousDurationInMs | |
? segment.endTimeInMs | |
: previousDurationInMs | |
set({ | |
segments, | |
allSegmentsChanged: previousAllSegmentsChanged + 1, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged + 1, | |
durationInMs, | |
...computeContentSizeMetrics({ | |
width, | |
height, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
}) | |
}) | |
}, | |
findFreeTrack: ({ | |
startTimeInMs, | |
endTimeInMs | |
}: { | |
startTimeInMs?: number | |
endTimeInMs?: number | |
}): number => { | |
const { segments } = get() | |
return findFreeTrack({ segments, startTimeInMs, endTimeInMs }) | |
}, | |
// resize and move the end of a segment, as well as the segment after it | |
fitSegmentToAssetDuration: async (segment: TimelineSegment, requestedDurationInMs?: number): Promise<void> => { | |
const { | |
width, | |
height, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
segments, | |
durationInMsPerStep, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged, | |
allSegmentsChanged: previousAllSegmentsChanged, | |
durationInMs: previousDurationInMs, | |
findFreeTrack, | |
assignTrack | |
} = get() | |
let requestedDuration: number = | |
typeof requestedDurationInMs === "number" && isFinite(requestedDurationInMs) && !isNaN(requestedDurationInMs) | |
? requestedDurationInMs | |
: segment.assetDurationInMs | |
// trivial case: nothing to do! | |
const segmentDurationInMs = segment.endTimeInMs - segment.startTimeInMs | |
if ( | |
requestedDuration === 0 | |
|| | |
segmentDurationInMs === requestedDuration | |
) { | |
return | |
} | |
// let's set some limits eg. at least 1 sec, I think this is reasonable | |
const minimumLengthInSteps = 2 | |
const minimumLengthInMs = minimumLengthInSteps * durationInMsPerStep | |
// positive if new duration is longer, | |
// negative if shorter | |
const timeDifferenceInMs = requestedDuration - segmentDurationInMs | |
// setup some limits | |
const newSegmentDurationInMs = Math.max( | |
minimumLengthInMs, | |
segmentDurationInMs + timeDifferenceInMs | |
) | |
// ok, well, there is nothing to change actually | |
if (segmentDurationInMs === newSegmentDurationInMs) { return } | |
// positive if new duration is longer, | |
// negative if shorter | |
const newTimeDifferenceInMs = newSegmentDurationInMs - segmentDurationInMs | |
const startTimeInMs = segment.startTimeInMs | |
const endTimeInMs = segment.endTimeInMs | |
// const newEndTimeInMs = endTimeInMs + newTimeDifferenceInMs | |
let durationInMs = previousDurationInMs | |
const referenceSegmentIsMusicOrSound = | |
segment.category === ClapSegmentCategory.MUSIC | |
|| segment.category === ClapSegmentCategory.SOUND | |
let segmentsToDelete: string[] = [] | |
for (const s of segments) { | |
// our strategy will be different depending on the type of segment | |
// basically, if it's a sound or a music, we don't need to cut the segment, | |
// and we don't have to extend the current shot. | |
// instead, we can let it go outerbound, although this creates 2 problems: | |
// 1. overlapping with another music/sound (on the same track or not) | |
// -> fix is easy, we can resize or delete completely the other one | |
// 2. collision with an item on the same track (eg. of a different type) | |
// -> fix is annoying, for now the a quick solution is to put the segment | |
// onto its own free track | |
const currentSegmentIsMusicOrSound = | |
s.category === ClapSegmentCategory.MUSIC | |
|| s.category === ClapSegmentCategory.SOUND | |
const isSameCategoryAsReferenceSegment = s.category === segment.category | |
const isSamePromptAsReferenceSegment = s.prompt === segment.prompt | |
if (referenceSegmentIsMusicOrSound) { | |
if (isSameCategoryAsReferenceSegment && isSamePromptAsReferenceSegment) { | |
if (s.endTimeInMs <= endTimeInMs) { | |
// we delete | |
console.log("TODO JULIAN: DELETE SEGMENT", s) | |
// segmentsToDelete.push(s.id) | |
// note: | |
} else if (s.startTimeInMs < endTimeInMs) { | |
// we resize | |
console.log("TODO JULIAN: resize segment") | |
// s.startTimeInMs = endTimeInMs | |
} | |
} | |
// independently, we run our collision detector | |
// it is important at this stage to take into account any change, | |
// eg. if we've already deleted segment `s` there is no need to | |
// assign a new free track | |
const isSameTrackAsReferenceSegment = s.track === segment.track | |
if (isSameTrackAsReferenceSegment) { | |
// if we have a collision, there is currently no way around it we need to create a new track | |
if (!(s.endTimeInMs <= startTimeInMs || s.startTimeInMs >= endTimeInMs)) { | |
const newTrack = findFreeTrack({ startTimeInMs, endTimeInMs }) | |
//console.log(`ASSIGN NEW TRACK (${newTrack}) TO SEGMENT`, s) | |
assignTrack({ | |
segment, | |
track: newTrack, | |
// we don't want to trigger a state change | |
triggerChange: false, | |
}) | |
} | |
} | |
} else { | |
// this is a dialogue or a video, we can apply our regular strategy | |
if (endTimeInMs <= s.startTimeInMs) { | |
s.startTimeInMs += newTimeDifferenceInMs | |
} | |
if (endTimeInMs <= s.endTimeInMs) { | |
s.endTimeInMs += newTimeDifferenceInMs | |
} | |
// also need to update the total duration | |
if (s.endTimeInMs > durationInMs) { | |
durationInMs = s.endTimeInMs | |
} | |
} | |
} | |
//console.log(`TODO Julian: stretched segments (overlapping segments) should be re-generated`) | |
set({ | |
segments, | |
allSegmentsChanged: previousAllSegmentsChanged + 1, | |
atLeastOneSegmentChanged: previousAtLeastOneSegmentChanged + 1, | |
durationInMs, | |
...computeContentSizeMetrics({ | |
width, | |
height, | |
tracks, | |
cellWidth, | |
defaultSegmentDurationInSteps, | |
durationInMsPerStep, | |
durationInMs, | |
}) | |
}) | |
}, | |
deleteSegments: (ids: string[]): void => { | |
const { | |
segments: previousSegments, | |
allSegmentsChanged, | |
atLeastOneSegmentChanged, | |
silentChangesInSegment, | |
} = get() | |
const deletables = new Set(ids) | |
const newSegments = previousSegments.filter(({ id }) => { | |
silentChangesInSegment[id] = 1 + (silentChangesInSegment[id] || 0) | |
return !deletables.has(id) | |
}) | |
set({ | |
segments: newSegments, | |
allSegmentsChanged: 1 + allSegmentsChanged, | |
atLeastOneSegmentChanged: 1 + atLeastOneSegmentChanged, | |
silentChangesInSegment, | |
}) | |
}, | |
addEntities: async (newEntities: ClapEntity[]) => { | |
const { | |
entities: previousEntities, | |
entityIndex: previousentityIndex, | |
entitiesChanged: previousEntitiesChanged, | |
} = get() | |
let somethingChanged = false | |
for (const newEntity of newEntities) { | |
if (previousentityIndex[newEntity.id]) { | |
// entity already exists | |
continue | |
} | |
previousEntities.push(newEntity) | |
previousentityIndex[newEntity.id] = newEntity | |
somethingChanged = true | |
} | |
if (somethingChanged) { | |
set({ | |
entities: previousEntities, | |
entityIndex: previousentityIndex, | |
entitiesChanged: previousEntitiesChanged + 1, | |
}) | |
} | |
}, | |
updateEntities: async (newEntities: ClapEntity[]) => { | |
const { | |
entities: previousEntities, | |
entityIndex: previousentityIndex, | |
entitiesChanged: previousEntitiesChanged, | |
} = get() | |
let somethingChanged = false | |
for (const newEntity of newEntities) { | |
const entity = previousentityIndex[newEntity.id] | |
if (!entity) { | |
// entity doesn't exist | |
continue | |
} | |
Object.assign(entity, newEntity) | |
// to optimize things, we could check if the assign really did change something | |
somethingChanged = true | |
} | |
if (somethingChanged) { | |
set({ | |
entities: previousEntities, | |
entityIndex: previousentityIndex, | |
entitiesChanged: previousEntitiesChanged + 1, | |
}) | |
} | |
}, | |
deleteEntities: async (entitiesToDelete: (ClapEntity|string)[]) => { | |
const { | |
entities: previousEntities, | |
entityIndex: previousentityIndex, | |
entitiesChanged: previousEntitiesChanged, | |
} = get() | |
let idsToDelete: string[] = [] | |
for (const newEntityOrId of entitiesToDelete) { | |
const id = typeof newEntityOrId === "string" ? newEntityOrId : newEntityOrId.id | |
delete previousentityIndex[id] | |
idsToDelete.push(id) | |
} | |
if (idsToDelete.length) { | |
set({ | |
entities: previousEntities.filter(e => !idsToDelete.includes(e.id)), | |
entityIndex: previousentityIndex, | |
entitiesChanged: previousEntitiesChanged + 1, | |
}) | |
} | |
}, | |
setInvalidate: (invalidate?: Invalidate) => { | |
set({ invalidate: invalidate || (() => {}) }) | |
} | |
} | |
)) | |
if (typeof window !== 'undefined') { | |
(window as any).useTimeline = useTimeline | |
} | |