257 lines
8.3 KiB
TypeScript
257 lines
8.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { Material } from "@/shared/types/material";
|
|
|
|
export interface TimelineSegment {
|
|
id: string;
|
|
materialId: string;
|
|
materialName: string;
|
|
start: number;
|
|
end: number;
|
|
sourceStart: number;
|
|
sourceEnd: number;
|
|
color: string;
|
|
}
|
|
|
|
export interface CustomAssignment {
|
|
material_path: string;
|
|
start: number;
|
|
end: number;
|
|
source_start: number;
|
|
source_end?: number;
|
|
}
|
|
|
|
const COLORS = ["#8b5cf6", "#ec4899", "#06b6d4", "#f59e0b", "#10b981", "#f97316"];
|
|
|
|
/** Serializable subset for localStorage */
|
|
interface SegmentSnapshot {
|
|
materialId: string;
|
|
start: number;
|
|
end: number;
|
|
sourceStart: number;
|
|
sourceEnd: number;
|
|
}
|
|
|
|
/** Get effective duration of a segment (clipped range or full material duration) */
|
|
function getEffectiveDuration(
|
|
seg: { sourceStart: number; sourceEnd: number; materialId: string },
|
|
mats: Material[]
|
|
): number {
|
|
const mat = mats.find((m) => m.id === seg.materialId);
|
|
const matDur = mat?.duration_sec ?? 0;
|
|
if (seg.sourceEnd > seg.sourceStart) return seg.sourceEnd - seg.sourceStart;
|
|
if (seg.sourceStart > 0) return Math.max(matDur - seg.sourceStart, 0);
|
|
return matDur;
|
|
}
|
|
|
|
/**
|
|
* Recalculate segment start/end positions based on effective durations.
|
|
* - Segments placed sequentially by effective duration
|
|
* - Segments exceeding audioDuration keep their positions (overflow, start >= duration)
|
|
* - Last visible segment is capped/extended to exactly audioDuration (loop fill)
|
|
*/
|
|
function recalcPositions(
|
|
segs: TimelineSegment[],
|
|
mats: Material[],
|
|
duration: number
|
|
): TimelineSegment[] {
|
|
if (segs.length === 0 || duration <= 0) return segs;
|
|
|
|
const fallbackDur = duration / segs.length;
|
|
let cursor = 0;
|
|
const result = segs.map((seg) => {
|
|
const effDur = getEffectiveDuration(seg, mats);
|
|
const dur = effDur > 0 ? effDur : fallbackDur;
|
|
const newSeg = { ...seg, start: cursor, end: cursor + dur };
|
|
cursor += dur;
|
|
return newSeg;
|
|
});
|
|
|
|
// Find last segment that starts before audioDuration
|
|
let lastVisibleIdx = -1;
|
|
for (let i = result.length - 1; i >= 0; i--) {
|
|
if (result[i].start < duration) {
|
|
lastVisibleIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Cap/extend last visible segment to exactly audioDuration
|
|
if (lastVisibleIdx >= 0) {
|
|
result[lastVisibleIdx] = { ...result[lastVisibleIdx], end: duration };
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
interface UseTimelineEditorOptions {
|
|
audioDuration: number;
|
|
materials: Material[];
|
|
selectedMaterials: string[];
|
|
storageKey?: string;
|
|
}
|
|
|
|
export const useTimelineEditor = ({
|
|
audioDuration,
|
|
materials,
|
|
selectedMaterials,
|
|
storageKey,
|
|
}: UseTimelineEditorOptions) => {
|
|
const [segments, setSegments] = useState<TimelineSegment[]>([]);
|
|
const prevKey = useRef("");
|
|
const restoredRef = useRef(false);
|
|
|
|
// Refs for stable callbacks (avoid recreating on every materials/duration change)
|
|
const materialsRef = useRef(materials);
|
|
const audioDurationRef = useRef(audioDuration);
|
|
|
|
useEffect(() => {
|
|
materialsRef.current = materials;
|
|
}, [materials]);
|
|
|
|
useEffect(() => {
|
|
audioDurationRef.current = audioDuration;
|
|
}, [audioDuration]);
|
|
|
|
// Build a durationsKey so segments re-init when material durations become available
|
|
const durationsKey = selectedMaterials
|
|
.map((id) => materials.find((m) => m.id === id)?.duration_sec ?? 0)
|
|
.join(",");
|
|
|
|
// Build a cache key from materials + duration
|
|
const cacheKey = `${selectedMaterials.join(",")}_${audioDuration.toFixed(1)}`;
|
|
const lsKey = storageKey ? `vigent_${storageKey}_timeline` : null;
|
|
|
|
const initSegments = useCallback(() => {
|
|
if (selectedMaterials.length === 0 || audioDuration <= 0) {
|
|
setSegments([]);
|
|
return;
|
|
}
|
|
|
|
// Try restore from localStorage
|
|
if (lsKey) {
|
|
try {
|
|
const raw = localStorage.getItem(lsKey);
|
|
if (raw) {
|
|
const saved = JSON.parse(raw) as { key: string; segments: SegmentSnapshot[] };
|
|
if (saved.key === cacheKey && saved.segments.length === selectedMaterials.length) {
|
|
const allMatch = saved.segments.every(
|
|
(s, i) => s.materialId === selectedMaterials[i] || saved.segments.some((ss) => ss.materialId === selectedMaterials[i])
|
|
);
|
|
if (allMatch) {
|
|
const restored: TimelineSegment[] = saved.segments.map((s, i) => {
|
|
const mat = materials.find((m) => m.id === s.materialId);
|
|
return {
|
|
id: `seg-${i}-${Date.now()}`,
|
|
materialId: s.materialId,
|
|
materialName: mat?.scene || mat?.name || s.materialId,
|
|
start: 0,
|
|
end: 0,
|
|
sourceStart: s.sourceStart,
|
|
sourceEnd: s.sourceEnd,
|
|
color: COLORS[i % COLORS.length],
|
|
};
|
|
});
|
|
setSegments(recalcPositions(restored, materials, audioDuration));
|
|
restoredRef.current = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
}
|
|
|
|
// Create fresh segments — positions derived by recalcPositions
|
|
const newSegments: TimelineSegment[] = selectedMaterials.map((matId, i) => {
|
|
const mat = materials.find((m) => m.id === matId);
|
|
return {
|
|
id: `seg-${i}-${Date.now()}`,
|
|
materialId: matId,
|
|
materialName: mat?.scene || mat?.name || matId,
|
|
start: 0,
|
|
end: 0,
|
|
sourceStart: 0,
|
|
sourceEnd: 0,
|
|
color: COLORS[i % COLORS.length],
|
|
};
|
|
});
|
|
|
|
setSegments(recalcPositions(newSegments, materials, audioDuration));
|
|
}, [audioDuration, materials, selectedMaterials, lsKey, cacheKey]);
|
|
|
|
// Auto-init when selectedMaterials, audioDuration, or material durations change
|
|
useEffect(() => {
|
|
const key = `${selectedMaterials.join(",")}_${audioDuration}_${durationsKey}`;
|
|
if (key !== prevKey.current) {
|
|
prevKey.current = key;
|
|
initSegments();
|
|
}
|
|
}, [selectedMaterials, audioDuration, durationsKey, initSegments]);
|
|
|
|
// Persist segments to localStorage on change (debounced)
|
|
useEffect(() => {
|
|
if (!lsKey || segments.length === 0) return;
|
|
const timeout = setTimeout(() => {
|
|
const snapshots: SegmentSnapshot[] = segments.map((s) => ({
|
|
materialId: s.materialId,
|
|
start: s.start,
|
|
end: s.end,
|
|
sourceStart: s.sourceStart,
|
|
sourceEnd: s.sourceEnd,
|
|
}));
|
|
localStorage.setItem(lsKey, JSON.stringify({ key: cacheKey, segments: snapshots }));
|
|
}, 300);
|
|
return () => clearTimeout(timeout);
|
|
}, [segments, lsKey, cacheKey]);
|
|
|
|
const reorderSegments = useCallback(
|
|
(fromIdx: number, toIdx: number) => {
|
|
setSegments((prev) => {
|
|
if (fromIdx < 0 || toIdx < 0 || fromIdx >= prev.length || toIdx >= prev.length) return prev;
|
|
if (fromIdx === toIdx) return prev;
|
|
const next = [...prev];
|
|
// Move the segment: remove from old position, insert at new position
|
|
const [moved] = next.splice(fromIdx, 1);
|
|
next.splice(toIdx, 0, moved);
|
|
return recalcPositions(next, materialsRef.current, audioDurationRef.current);
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const setSourceRange = useCallback(
|
|
(id: string, sourceStart: number, sourceEnd: number) => {
|
|
setSegments((prev) => {
|
|
const updated = prev.map((s) => (s.id === id ? { ...s, sourceStart, sourceEnd } : s));
|
|
return recalcPositions(updated, materialsRef.current, audioDurationRef.current);
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const toCustomAssignments = useCallback((): CustomAssignment[] => {
|
|
const duration = audioDurationRef.current;
|
|
return segments
|
|
.filter((seg) => seg.start < duration)
|
|
.map((seg) => {
|
|
const mat = materialsRef.current.find((m) => m.id === seg.materialId);
|
|
return {
|
|
material_path: mat?.path || seg.materialId,
|
|
start: seg.start,
|
|
end: seg.end,
|
|
source_start: seg.sourceStart,
|
|
source_end: seg.sourceEnd > seg.sourceStart ? seg.sourceEnd : undefined,
|
|
};
|
|
});
|
|
}, [segments]);
|
|
|
|
return {
|
|
segments,
|
|
initSegments,
|
|
reorderSegments,
|
|
setSourceRange,
|
|
toCustomAssignments,
|
|
};
|
|
};
|