150 lines
5.5 KiB
JavaScript
150 lines
5.5 KiB
JavaScript
export const H3_FPS = 24;
|
|
export const TIMELINE_VERSION = 2;
|
|
export const DEFAULT_FRAME_COUNT = 124;
|
|
|
|
export const DEFAULT_STATE = Object.freeze({
|
|
version: TIMELINE_VERSION,
|
|
fps: H3_FPS,
|
|
timeline: Object.freeze({
|
|
frame_count: DEFAULT_FRAME_COUNT,
|
|
selected_frame: 0,
|
|
}),
|
|
guides: Object.freeze([]),
|
|
});
|
|
|
|
export function cloneDefaultState() {
|
|
return {
|
|
version: TIMELINE_VERSION,
|
|
fps: H3_FPS,
|
|
timeline: { frame_count: DEFAULT_FRAME_COUNT, selected_frame: 0 },
|
|
guides: [],
|
|
};
|
|
}
|
|
|
|
function asSlot(value) {
|
|
if (value === null || value === undefined || value === '') return null;
|
|
const slot = Number.parseInt(value, 10);
|
|
return Number.isInteger(slot) && slot >= 0 ? slot : null;
|
|
}
|
|
|
|
function asFrame(value) {
|
|
return Math.max(0, Math.round(Number(value) || 0));
|
|
}
|
|
|
|
function normalizeGuide(value, index) {
|
|
return {
|
|
id: String(value?.id || `guide-${index + 1}`),
|
|
enabled: value?.enabled !== false,
|
|
frame: asFrame(value?.frame),
|
|
image_slot: asSlot(value?.image_slot),
|
|
audio_slot: asSlot(value?.audio_slot),
|
|
};
|
|
}
|
|
|
|
function normalizeTimeline(value, fallbackSelected = 0) {
|
|
const rawFrameCount = Number(value?.frame_count);
|
|
const frameCount = Math.max(1, Math.round(Number.isFinite(rawFrameCount) ? rawFrameCount : DEFAULT_FRAME_COUNT));
|
|
const selected = Math.min(frameCount - 1, asFrame(value?.selected_frame ?? fallbackSelected));
|
|
return { frame_count: frameCount, selected_frame: selected };
|
|
}
|
|
|
|
export function normalizeState(value) {
|
|
if (!value || ![1, TIMELINE_VERSION].includes(Number(value.version)) || Number(value.fps ?? H3_FPS) !== H3_FPS) {
|
|
return cloneDefaultState();
|
|
}
|
|
const guides = Array.isArray(value.guides) ? value.guides : [];
|
|
const legacySelected = value.reference?.selected_frame ?? 0;
|
|
const timeline = Number(value.version) === 1
|
|
? normalizeTimeline({ frame_count: DEFAULT_FRAME_COUNT, selected_frame: legacySelected })
|
|
: normalizeTimeline(value.timeline, legacySelected);
|
|
return {
|
|
version: TIMELINE_VERSION,
|
|
fps: H3_FPS,
|
|
timeline,
|
|
guides: guides.map(normalizeGuide),
|
|
};
|
|
}
|
|
|
|
export function parseState(json) {
|
|
if (!json) return cloneDefaultState();
|
|
try {
|
|
return normalizeState(typeof json === 'string' ? JSON.parse(json) : json);
|
|
} catch {
|
|
return cloneDefaultState();
|
|
}
|
|
}
|
|
|
|
export function serializeState(state) {
|
|
return JSON.stringify(normalizeState(state));
|
|
}
|
|
|
|
export function framesToSeconds(frame) {
|
|
return Math.max(0, Number(frame) || 0) / H3_FPS;
|
|
}
|
|
|
|
export function secondsToFrames(seconds) {
|
|
return Math.max(0, Math.round((Number(seconds) || 0) * H3_FPS));
|
|
}
|
|
|
|
const SNAP_STEPS = Object.freeze({ frame: 1, '6f': 6, '12f': 12, '24f': 24 });
|
|
|
|
function snapH3Frame(value) {
|
|
const first = 5;
|
|
if (value <= first) return first;
|
|
return first + Math.round((value - first) / 17) * 17;
|
|
}
|
|
|
|
export function snapFrame(frame, mode = 'frame') {
|
|
const value = Math.max(0, Number(frame) || 0);
|
|
if (mode === 'h3') return snapH3Frame(value);
|
|
const step = SNAP_STEPS[mode] || 1;
|
|
return Math.max(0, Math.round(value / step) * step);
|
|
}
|
|
|
|
export function frameCountFromNode(node, fallback = DEFAULT_FRAME_COUNT) {
|
|
const input = (node?.inputs || []).find((item) => String(item?.name || '').endsWith('frame_count'));
|
|
if (input?.link == null) return Math.max(1, Math.round(Number(fallback) || DEFAULT_FRAME_COUNT));
|
|
const widget = (node?.widgets || []).find((item) => item?.name === 'frame_count');
|
|
const link = node?.graph?.links?.[input.link] || node?.graph?.links?.[String(input.link)];
|
|
const source = link && (node.graph.getNodeById?.(link.origin_id) || node.graph._nodes_by_id?.[link.origin_id]);
|
|
const sourceWidget = source?.widgets?.find((item) => ['value', 'int', 'frames', 'frame_count'].includes(item?.name));
|
|
const sourceValue = sourceWidget?.value
|
|
?? source?.widgets_values_named?.value
|
|
?? source?.widgets_values_named?.int
|
|
?? source?.widgets_values_named?.frames
|
|
?? source?.widgets_values_named?.frame_count;
|
|
if (sourceValue != null) {
|
|
const sourceType = String(link?.type ?? source?.outputs?.[link?.origin_slot]?.type ?? 'INT');
|
|
const numeric = Number(sourceValue);
|
|
if (sourceType.split(',').includes('FLOAT') && Number.isFinite(numeric) && numeric > 0) {
|
|
const scaled = numeric * H3_FPS;
|
|
const lower = Math.floor(scaled);
|
|
const rounded = scaled - lower === 0.5 ? lower + (lower % 2) : Math.round(scaled);
|
|
const base = Math.max(5, rounded);
|
|
return Math.min(3600, base + ((5 - (base % 17)) + 17) % 17);
|
|
}
|
|
}
|
|
const raw = source ? sourceValue ?? fallback : widget?.value ?? node?.widgets_values_named?.frame_count ?? fallback;
|
|
const value = Number(raw);
|
|
return Number.isFinite(value) ? Math.min(3600, Math.max(1, Math.round(value))) : Math.max(1, Math.round(Number(fallback) || DEFAULT_FRAME_COUNT));
|
|
}
|
|
|
|
export function frameCountFromExecution(output, fallback = DEFAULT_FRAME_COUNT) {
|
|
const raw = Array.isArray(output?.frame_count) ? output.frame_count[0] : output?.frame_count;
|
|
const value = Number(raw);
|
|
return Number.isFinite(value) ? Math.min(3600, Math.max(1, Math.round(value))) : Math.max(1, Math.round(Number(fallback) || DEFAULT_FRAME_COUNT));
|
|
}
|
|
|
|
export function formatFrameLabel(frame) {
|
|
return `F${Math.max(0, Math.round(Number(frame) || 0))}`;
|
|
}
|
|
|
|
export function timelineTickFrames(totalFrames) {
|
|
const total = Math.max(1, Math.round(Number(totalFrames) || 1));
|
|
const step = total <= 144 ? 24 : total <= 288 ? 48 : total <= 576 ? 96 : 192;
|
|
const ticks = [];
|
|
for (let frame = 0; frame <= total; frame += step) ticks.push(frame);
|
|
if (ticks[ticks.length - 1] !== total) ticks.push(total);
|
|
return ticks;
|
|
}
|