Download frontend/src/lib/timeline-utils.ts from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/lib/timeline-utils.ts
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/frontend/src/lib/timeline-utils.ts
-
curl -L -o timeline-utils.ts https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/lib/timeline-utils.ts
11 kB
| import type { ImageItem, TimelineData, Track, TrackType } from '@/types/timeline' | |
| import { uuid } from './uuid' | |
| import { traceToRootSource } from './graph-utils' | |
| // --------------------------------------------------------------------------- | |
| // Slot item types (used by MediaSelector's slot tab) | |
| // --------------------------------------------------------------------------- | |
| export interface SlotItem { | |
| /** Display label, e.g. "image", "image_0", "audio_1" */ | |
| label: string | |
| /** Encoded value passed to onChange, e.g. "__slot__:image_0" */ | |
| value: string | |
| /** Preview image src for image slots */ | |
| img?: string | |
| /** Audio name for audio slots */ | |
| audio_name?: string | |
| /** Input file selected by a LoadVideo source, when available */ | |
| video_name?: string | |
| } | |
| interface SlotInputDescriptor { | |
| name: string | |
| type: string | |
| link?: number | null | |
| } | |
| interface SlotLinkDescriptor { | |
| origin_id: number | |
| origin_slot: number | |
| } | |
| interface SlotSourceNodeDescriptor { | |
| type?: string | |
| inputs?: SlotInputDescriptor[] | |
| widgets?: Array<{ name?: string; value?: unknown }> | |
| widgets_values?: unknown[] | |
| } | |
| function loadVideoFileName(sourceNode: SlotSourceNodeDescriptor | undefined): string | undefined { | |
| if (sourceNode?.type !== 'LoadVideo') return undefined | |
| const file = sourceNode.widgets?.find((widget) => widget.name === 'file')?.value | |
| ?? sourceNode.widgets_values?.[0] | |
| return typeof file === 'string' && file.trim() ? file : undefined | |
| } | |
| function getInputLink( | |
| currentNode: any, | |
| input: SlotInputDescriptor, | |
| inputIndex: number, | |
| graph: any, | |
| ): SlotLinkDescriptor | null { | |
| try { | |
| const link = currentNode?.getInputLink?.(inputIndex) | |
| if (link) return link | |
| } catch (error) { | |
| console.error('[timeline-utils] failed to read input link:', error) | |
| } | |
| const legacyLinkId = input.link | |
| if (legacyLinkId === null || legacyLinkId === undefined) return null | |
| return graph?.links?.[legacyLinkId] ?? null | |
| } | |
| function sourceListSkipsEmpty( | |
| sourceNode: SlotSourceNodeDescriptor, | |
| mediaType: 'image' | 'audio' | 'video', | |
| ): boolean { | |
| const widget = sourceNode.widgets?.find((candidate) => ( | |
| candidate?.name === 'skip_empty' | |
| )) | |
| if (typeof widget?.value === 'boolean') return widget.value | |
| const skipEmptyInputIndex = sourceNode.inputs | |
| ?.findIndex((input) => input.name === 'skip_empty') ?? -1 | |
| const serializedValue = skipEmptyInputIndex >= 0 | |
| ? sourceNode.widgets_values?.[skipEmptyInputIndex] | |
| : undefined | |
| if (typeof serializedValue === 'boolean') return serializedValue | |
| // Match the defaults of the bundled Make*List nodes when runtime widget | |
| // metadata is unavailable (for example while loading an older workflow). | |
| return mediaType !== 'audio' | |
| } | |
| /** | |
| * Inspect a ComfyUI node's optional inputs and the upstream graph to build | |
| * a list of selectable slot items for the given media type. | |
| * | |
| * Logic: | |
| * - Find node.inputs whose type matches mediaType (case-insensitive) | |
| * - For each connected input (link !== null): | |
| * - Trace the link to the source node's output slot | |
| * - If the source output is_list → enumerate this node's image_N / audio_N inputs | |
| * - Otherwise → one item for that input name | |
| */ | |
| export function computeSlotItems( | |
| node: any, | |
| app: any, | |
| mediaType: 'image' | 'audio' | 'video', | |
| ): SlotItem[] { | |
| if (!node?.inputs || !app?.graph) return [] | |
| const result: SlotItem[] = [] | |
| const type = mediaType.toUpperCase() | |
| for (const [inputIndex, input] of (node.inputs as SlotInputDescriptor[]).entries()) { | |
| if ((input.type ?? '').toUpperCase() !== type) continue | |
| const link = getInputLink(node, input, inputIndex, app.graph) | |
| if (!link) continue | |
| const sourceNodeId: number = link.origin_id | |
| const sourceSlotIdx: number = link.origin_slot | |
| const sourceNode = app.graph.getNodeById(sourceNodeId) | |
| if (!sourceNode) continue | |
| const sourceOutputSlot = sourceNode.outputs?.[sourceSlotIdx] | |
| const isList = sourceOutputSlot.shape == 6 | |
| if (isList) { | |
| // Collect this node's indexed inputs: image_0, image_1, ... / audio_0, audio_1, ... | |
| const indexedInputs = (sourceNode.inputs as SlotInputDescriptor[]) | |
| .map((inp, index) => ({ inp, index, link: getInputLink(sourceNode, inp, index, app.graph) })) | |
| .filter(({ inp, link }) => (inp.type ?? '').toUpperCase() === type && link !== null) | |
| .sort((a, b) => { | |
| const ia = parseInt(a.inp.name.match(/(\d+)$/)?.[1] ?? '0', 10) | |
| const ib = parseInt(b.inp.name.match(/(\d+)$/)?.[1] ?? '0', 10) | |
| return ia - ib | |
| }) | |
| if (indexedInputs.length > 0) { | |
| const skipEmpty = sourceListSkipsEmpty(sourceNode, mediaType) | |
| for (const [connectedIndex, { inp, link: inputLink }] of indexedInputs.entries()) { | |
| let imgsrc: string | undefined | |
| let audio_name: string | undefined | |
| let video_name: string | undefined | |
| if (inputLink) { | |
| const currentNodeId = traceToRootSource(inputLink.origin_id, inputLink.origin_slot, app.graph) | |
| if (currentNodeId !== null) { | |
| const currentNode = app.graph.getNodeById(currentNodeId) | |
| if (mediaType == 'image') { | |
| imgsrc = currentNode?.imgs?.[0]?.currentSrc | |
| } else if (mediaType == 'audio') { | |
| audio_name = (currentNode.widgets_values?.[0] as string) ?? (currentNode.widgets?.[0]?.value as string) | |
| } else if (mediaType === 'video') { | |
| video_name = loadVideoFileName(currentNode) | |
| } | |
| } | |
| } | |
| const slotName = skipEmpty ? `${mediaType}${connectedIndex + 1}` : inp.name | |
| result.push({ label: inp.name, img: imgsrc, audio_name, video_name, value: `__slot__:${slotName}` }) | |
| } | |
| } else { | |
| // Fallback: represent base input as a single list reference | |
| } | |
| } else { | |
| // Non-list: sourceNode is the output node (e.g., VAEDecode, LoadImage) | |
| // Try to trace to find the actual image source | |
| let imgsrc: string | undefined | |
| let audio_name: string | undefined | |
| let video_name: string | undefined | |
| const currentNodeId = traceToRootSource(link.origin_id, link.origin_slot, app.graph) | |
| const node = currentNodeId !== null | |
| ? app.graph.getNodeById(currentNodeId) | |
| : sourceNode | |
| if (mediaType == 'image') { | |
| imgsrc = node?.imgs?.[0]?.currentSrc | |
| } else if (mediaType == 'audio' && node?.type == 'LoadAudio') { | |
| audio_name = (node.widgets_values?.[0] as string) ?? (node.widgets?.[0]?.value as string) | |
| } else if (mediaType === 'video') { | |
| video_name = loadVideoFileName(node) | |
| } | |
| result.push({ label: input.name, img: imgsrc, audio_name, video_name, value: `__slot__:${input.name}` }) | |
| } | |
| // Only process the first connected input of this type | |
| break | |
| } | |
| return result | |
| } | |
| export function scaleImageItemsToDuration( | |
| images: ImageItem[], | |
| oldDuration: number, | |
| newDuration: number, | |
| ): ImageItem[] { | |
| if (oldDuration <= 0 || newDuration <= 0) return images | |
| return images.map((image) => { | |
| if (image.start_frame === undefined || image.end_frame === undefined) { | |
| return image | |
| } | |
| const start = Math.max( | |
| 0, | |
| Math.min(newDuration - 1, Math.round((image.start_frame / oldDuration) * newDuration)), | |
| ) | |
| const exclusiveEnd = Math.round(((image.end_frame + 1) / oldDuration) * newDuration) | |
| const end = Math.max(start, Math.min(newDuration - 1, exclusiveEnd - 1)) | |
| return { | |
| ...image, | |
| start_frame: start, | |
| end_frame: end, | |
| } | |
| }) | |
| } | |
| /** | |
| * Converts seconds to frame count using the formula from the spec: | |
| * frames = ceil(seconds * frameRate / 4) * 4 + 1 | |
| */ | |
| export function secondsToFrames(seconds: number, frameRate: number): number { | |
| return Math.ceil((seconds * frameRate) / 4) * 4 + 1 | |
| } | |
| /** | |
| * Converts seconds to frame count for audio playback (no +1 offset). | |
| * Use this when computing audio segment duration or seeking. | |
| * frames = ceil(seconds * frameRate) | |
| */ | |
| export function secondsToAudioFrames(seconds: number, frameRate: number): number { | |
| return Math.ceil(seconds * frameRate) | |
| } | |
| /** | |
| * Converts frame count back to seconds. | |
| * seconds = (frames - 1) / frameRate | |
| */ | |
| export function framesToSeconds(frames: number, frameRate: number): number { | |
| return (frames - 1) / frameRate | |
| } | |
| /** | |
| * Format a frame index as a display string. | |
| * mode='frames' → "121f" | |
| * mode='seconds' → "5.00s" | |
| */ | |
| export function formatTime( | |
| frames: number, | |
| frameRate: number, | |
| mode: 'frames' | 'seconds', | |
| ): string { | |
| if (mode === 'frames') return `${frames}f` | |
| const secs = framesToSeconds(frames, frameRate) | |
| return `${secs.toFixed(2)}s` | |
| } | |
| /** | |
| * Parse a user-entered time string to frames. | |
| * Accepts "121" (treated as frames), "121f", "5s", "5.0s". | |
| * Returns NaN if unparseable. | |
| */ | |
| export function parseTimeInput(input: string, frameRate: number): number { | |
| const trimmed = input.trim() | |
| if (trimmed.endsWith('s')) { | |
| const secs = Number.parseFloat(trimmed.slice(0, -1)) | |
| if (Number.isNaN(secs)) return Number.NaN | |
| return secondsToFrames(secs, frameRate) | |
| } | |
| const framesStr = trimmed.endsWith('f') ? trimmed.slice(0, -1) : trimmed | |
| const frames = Number.parseInt(framesStr, 10) | |
| return Number.isNaN(frames) ? Number.NaN : frames | |
| } | |
| /** Default colors per track type */ | |
| export const TRACK_DEFAULT_COLORS: Record<TrackType, string> = { | |
| audio: '#34d399', | |
| prompt: '#a78bfa', | |
| image: '#fb923c', | |
| video: '#60a5fa', | |
| maintain: 'var(--muted)', | |
| } | |
| /** Build a default empty TimelineData with one maintain track and one audio track */ | |
| export function createDefaultTimelineData(t?: (path: string, params?: Record<string, string | number>) => string): TimelineData { | |
| const _t = t ?? ((_: string) => '') | |
| const total_length = 121 | |
| const frame_rate = 24 | |
| const tracks: Track[] = [ | |
| { | |
| id: uuid(), | |
| name: _t('timeline.defaultTrackName', { n: 1 }), | |
| type: 'maintain', | |
| color: TRACK_DEFAULT_COLORS.maintain, | |
| muted: false, | |
| locked: false, | |
| segments: [], | |
| }, | |
| { | |
| id: uuid(), | |
| name: _t('timeline.defaultAudioTrackName', { n: 1 }), | |
| type: 'audio', | |
| color: TRACK_DEFAULT_COLORS.audio, | |
| muted: false, | |
| locked: false, | |
| segments: [], | |
| }, | |
| ] | |
| return { tracks, total_length, frame_rate } | |
| } | |
| /** | |
| * Returns the pixel left offset and width for a segment on the track content area. | |
| * @param startFrame segment start frame (inclusive) | |
| * @param endFrame segment end frame (inclusive) | |
| * @param totalFrames total timeline length in frames | |
| * @param areaWidth pixel width of the track content area | |
| */ | |
| export function segmentPixelRect( | |
| startFrame: number, | |
| endFrame: number, | |
| totalFrames: number, | |
| areaWidth: number, | |
| ): { left: number; width: number } { | |
| const left = (startFrame / (totalFrames - 1)) * areaWidth | |
| const right = ((endFrame + 1) / (totalFrames - 1)) * areaWidth | |
| return { left, width: Math.max(right - left, 2) } | |
| } | |