Introduce end-to-end chapterized audiobook generation with persistent storage, resumable workflows, and MP3/M4B support. API: - add /api/audio/convert/chapter (GET/DELETE) for per-chapter ops - add /api/audio/convert/chapters (GET/DELETE) for listing/reset - enhance /api/audio/convert: - accept mp3|m4b, stream combined file, cache complete output - robust chapter indexing, docstore persistence, list concat - AbortSignal-aware ffmpeg/ffprobe, 499 on cancel UI/UX: - add AudiobookExportModal with progress, resume, regenerate, download - add ProgressCard, enhance ProgressPopup (click-to-focus, richer info) - add Header, ZoomControl; move TTSPlayer to sticky bottom bar - redesign EPUB/HTML/PDF pages for full-height layout and controls - add HomeContent; compact uploader variant; document list polish TTS/Contexts: - EPUB/PDF contexts now generate per-chapter to disk, return bookId - support chapter regeneration; progress/cancel propagation - pass provider/model/instructions; standardize MP3 TTS output - HTML context updates for model/instructions Styling: - globals: overlay-dim, scrollbar styles, prism gradient utilities - theme vars: secondary-accent, prism-gradient; tailwind color addition Misc: - fix PDF scale calc to use container height - EPUB theme reader area fills height - time estimation update cadence stab - audio util passes format to combine endpoint
192 lines
No EOL
6.5 KiB
TypeScript
192 lines
No EOL
6.5 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
|
export interface TimeEstimation {
|
|
progress: number;
|
|
setProgress: (progress: number) => void;
|
|
estimatedTimeRemaining: string | null;
|
|
}
|
|
|
|
export function useTimeEstimation(): TimeEstimation {
|
|
const [progress, setProgressState] = useState(0);
|
|
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState<string | null>(null);
|
|
|
|
// Store timing data to avoid dependency cycles
|
|
const startTimeRef = useRef<number | null>(null);
|
|
const progressHistoryRef = useRef<Array<{time: number, progress: number, textLength?: number}>>([]);
|
|
const lastProgressUpdateRef = useRef<number>(0);
|
|
const lastEstimateUpdateRef = useRef<number>(0);
|
|
|
|
const resetState = useCallback(() => {
|
|
startTimeRef.current = null;
|
|
progressHistoryRef.current = [];
|
|
setEstimatedTimeRemaining(null);
|
|
lastEstimateUpdateRef.current = 0;
|
|
lastProgressUpdateRef.current = 0;
|
|
}, []);
|
|
|
|
const formatTimeRemaining = useCallback((seconds: number): string => {
|
|
if (seconds < 30) {
|
|
return '1m';
|
|
} else if (seconds < 3600) {
|
|
const minutes = Math.round(seconds / 60);
|
|
return `${minutes}m`;
|
|
} else {
|
|
const totalMinutes = Math.round(seconds / 60);
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = totalMinutes % 60;
|
|
return minutes > 0
|
|
? `${hours}h ${minutes}m`
|
|
: `${hours}h`;
|
|
}
|
|
}, []);
|
|
|
|
const calculateMovingAverage = useCallback((data: number[], windowSize: number) => {
|
|
const result = [];
|
|
for (let i = 0; i < data.length; i++) {
|
|
const start = Math.max(0, i - windowSize + 1);
|
|
const window = data.slice(start, i + 1);
|
|
const average = window.reduce((sum, val) => sum + val, 0) / window.length;
|
|
result.push(average);
|
|
}
|
|
return result;
|
|
}, []);
|
|
|
|
const calculateTimeRemaining = useCallback((newProgress: number, currentTime: number) => {
|
|
// Initialize start time if this is the first meaningful update
|
|
if (startTimeRef.current === null && newProgress > 0) {
|
|
startTimeRef.current = currentTime;
|
|
progressHistoryRef.current = [{ time: currentTime, progress: newProgress }];
|
|
lastProgressUpdateRef.current = currentTime;
|
|
return;
|
|
}
|
|
|
|
// Skip if no progress
|
|
if (newProgress <= 0) {
|
|
resetState();
|
|
return;
|
|
}
|
|
|
|
// Check if we should update the estimate
|
|
const timeSinceLastUpdate = currentTime - lastProgressUpdateRef.current;
|
|
const shouldSkipUpdate = timeSinceLastUpdate < 3000; // Reduced from 10s to 3s for more responsive updates
|
|
|
|
const lastDataPoint = progressHistoryRef.current[progressHistoryRef.current.length - 1];
|
|
const timeSinceLastDataPoint = lastDataPoint ? currentTime - lastDataPoint.time : Infinity;
|
|
const progressDifference = lastDataPoint ? newProgress - lastDataPoint.progress : Infinity;
|
|
|
|
// Store data point if significant time or progress change
|
|
if (timeSinceLastDataPoint > 2000 || Math.abs(progressDifference) > 1) {
|
|
progressHistoryRef.current.push({ time: currentTime, progress: newProgress });
|
|
|
|
// Keep a sliding window of data points
|
|
const maxDataPoints = 20; // Increased from 5 for smoother averaging
|
|
if (progressHistoryRef.current.length > maxDataPoints) {
|
|
progressHistoryRef.current = [
|
|
progressHistoryRef.current[0],
|
|
...progressHistoryRef.current.slice(-maxDataPoints + 1)
|
|
];
|
|
}
|
|
}
|
|
|
|
if (shouldSkipUpdate) {
|
|
return;
|
|
}
|
|
|
|
lastProgressUpdateRef.current = currentTime;
|
|
|
|
const history = progressHistoryRef.current;
|
|
|
|
if (history.length < 2) {
|
|
return;
|
|
}
|
|
|
|
// Calculate progress rates for smoothing
|
|
const rates: number[] = [];
|
|
for (let i = 1; i < history.length; i++) {
|
|
const timeSpan = (history[i].time - history[i-1].time) / 1000; // Convert to seconds
|
|
const progressSpan = history[i].progress - history[i-1].progress;
|
|
if (timeSpan > 0) {
|
|
rates.push(progressSpan / timeSpan);
|
|
}
|
|
}
|
|
|
|
if (rates.length === 0) return;
|
|
|
|
// Apply moving average smoothing to the rates
|
|
const smoothedRates = calculateMovingAverage(rates, Math.min(5, rates.length));
|
|
const currentRate = smoothedRates[smoothedRates.length - 1];
|
|
|
|
if (currentRate > 0) {
|
|
const remainingProgress = 100 - newProgress;
|
|
const estimatedSeconds = remainingProgress / currentRate;
|
|
|
|
if (isFinite(estimatedSeconds) && estimatedSeconds > 0) {
|
|
// Apply adaptive dampening based on progress
|
|
const dampingFactor = Math.max(0.2, Math.min(0.8, newProgress / 100));
|
|
const previousEstimate = getSecondsFromEstimate(estimatedTimeRemaining);
|
|
|
|
const smoothedSeconds = previousEstimate
|
|
? (estimatedSeconds * dampingFactor) + (previousEstimate * (1 - dampingFactor))
|
|
: estimatedSeconds;
|
|
|
|
setEstimatedTimeRemaining(formatTimeRemaining(smoothedSeconds));
|
|
lastEstimateUpdateRef.current = currentTime;
|
|
}
|
|
}
|
|
}, [estimatedTimeRemaining, formatTimeRemaining, resetState, calculateMovingAverage]);
|
|
|
|
const getSecondsFromEstimate = (estimate: string | null): number | null => {
|
|
if (!estimate) return null;
|
|
|
|
let seconds = 0;
|
|
|
|
if (estimate.includes('h')) {
|
|
const hours = parseInt(estimate.split('h')[0], 10);
|
|
seconds += hours * 3600;
|
|
estimate = estimate.split('h')[1];
|
|
}
|
|
|
|
if (estimate?.includes('m')) {
|
|
const minutes = parseInt(estimate.split('m')[0], 10);
|
|
seconds += minutes * 60;
|
|
estimate = estimate.split('m')[1];
|
|
}
|
|
|
|
if (estimate?.includes('s')) {
|
|
const secs = parseInt(estimate.split('s')[0], 10);
|
|
seconds += secs;
|
|
}
|
|
|
|
return seconds;
|
|
};
|
|
|
|
const updateProgress = useCallback((newProgress: number) => {
|
|
const currentTime = Date.now();
|
|
const clampedProgress = Math.max(0, Math.min(100, newProgress));
|
|
|
|
setProgressState(clampedProgress);
|
|
|
|
if (clampedProgress === 0) {
|
|
resetState();
|
|
} else if (clampedProgress === 100) {
|
|
setEstimatedTimeRemaining(null);
|
|
lastEstimateUpdateRef.current = currentTime;
|
|
lastProgressUpdateRef.current = currentTime;
|
|
} else {
|
|
calculateTimeRemaining(clampedProgress, currentTime);
|
|
}
|
|
}, [calculateTimeRemaining, resetState]);
|
|
|
|
// Reset time estimation when component unmounts
|
|
useEffect(() => {
|
|
return () => {
|
|
resetState();
|
|
};
|
|
}, [resetState]);
|
|
|
|
return {
|
|
progress,
|
|
setProgress: updateProgress,
|
|
estimatedTimeRemaining
|
|
};
|
|
} |