diff --git a/packages/app/src/lib/core/constants.ts b/packages/app/src/lib/core/constants.ts index fded517b..015d212e 100644 --- a/packages/app/src/lib/core/constants.ts +++ b/packages/app/src/lib/core/constants.ts @@ -3,7 +3,7 @@ export const HARD_LIMIT_NB_MAX_ASSETS_TO_GENERATE_IN_PARALLEL = 32 export const APP_NAME = 'Clapper.app' -export const APP_REVISION = '20240902+0142' +export const APP_REVISION = '20240902+1208' export const APP_DOMAIN = 'Clapper.app' export const APP_LINK = 'https://clapper.app' diff --git a/packages/app/src/services/io/useIO.ts b/packages/app/src/services/io/useIO.ts index b406b750..89a90917 100644 --- a/packages/app/src/services/io/useIO.ts +++ b/packages/app/src/services/io/useIO.ts @@ -536,107 +536,107 @@ export const useIO = create((set, get) => ({ }) try { - const timeline: TimelineStore = useTimeline.getState() + const timeline: TimelineStore = useTimeline.getState() - const { - width, - height, - durationInMs, - segments: timelineSegments, - getClap, - } = timeline + const { + width, + height, + durationInMs, + segments: timelineSegments, + getClap, + } = timeline - const clap = await getClap() + const clap = await getClap() - if (!clap) { - throw new Error(`cannot save a clap.. if there is no clap`) - } + if (!clap) { + throw new Error(`cannot save a clap.. if there is no clap`) + } - const ignoreThisVideoSegmentId = (await getFinalVideo(clap))?.id || '' + const ignoreThisVideoSegmentId = (await getFinalVideo(clap))?.id || '' - const segments: ExportableSegment[] = timelineSegments - .map((segment, i) => formatSegmentForExport(segment, i)) - .filter( - ({ id, isExportableToFile }) => - isExportableToFile && id !== ignoreThisVideoSegmentId - ) + const segments: ExportableSegment[] = timelineSegments + .map((segment, i) => formatSegmentForExport(segment, i)) + .filter( + ({ id, isExportableToFile }) => + isExportableToFile && id !== ignoreThisVideoSegmentId + ) - const videos: FFMPegVideoInput[] = [] - const images: FFMPegVideoInput[] = [] - const audios: FFMPegAudioInput[] = [] - - segments.forEach( - ({ - segment, - prefix, - filePath, - assetUrl, - assetSourceType, - isExportableToFile, - }) => { - if (isExportableToFile) { - assetUrl = filePath - assetSourceType = ClapAssetSource.PATH - - if (filePath.startsWith('video/')) { - videos.push({ - data: base64DataUriToUint8Array(segment.assetUrl), - startTimeInMs: segment.startTimeInMs, - endTimeInMs: segment.endTimeInMs, - durationInSecs: segment.assetDurationInMs / 1000, - category: segment.category, - }) - } else if ( - filePath.startsWith('image/') || - segment.category === ClapSegmentCategory.IMAGE - ) { - images.push({ - data: base64DataUriToUint8Array(segment.assetUrl), - startTimeInMs: segment.startTimeInMs, - endTimeInMs: segment.endTimeInMs, - durationInSecs: - (segment.endTimeInMs - segment.startTimeInMs) / 1000, - category: segment.category, - }) - } else if ( - filePath.startsWith('music/') || - filePath.startsWith('sound/') || - filePath.startsWith('dialogue/') - ) { - audios.push({ - data: base64DataUriToUint8Array(segment.assetUrl), - startTimeInMs: segment.startTimeInMs, - endTimeInMs: segment.endTimeInMs, - durationInSecs: segment.assetDurationInMs / 1000, - category: segment.category, - }) + const videos: FFMPegVideoInput[] = [] + const images: FFMPegVideoInput[] = [] + const audios: FFMPegAudioInput[] = [] + + segments.forEach( + ({ + segment, + prefix, + filePath, + assetUrl, + assetSourceType, + isExportableToFile, + }) => { + if (isExportableToFile) { + assetUrl = filePath + assetSourceType = ClapAssetSource.PATH + + if (filePath.startsWith('video/')) { + videos.push({ + data: base64DataUriToUint8Array(segment.assetUrl), + startTimeInMs: segment.startTimeInMs, + endTimeInMs: segment.endTimeInMs, + durationInSecs: segment.assetDurationInMs / 1000, + category: segment.category, + }) + } else if ( + filePath.startsWith('image/') || + segment.category === ClapSegmentCategory.IMAGE + ) { + images.push({ + data: base64DataUriToUint8Array(segment.assetUrl), + startTimeInMs: segment.startTimeInMs, + endTimeInMs: segment.endTimeInMs, + durationInSecs: + (segment.endTimeInMs - segment.startTimeInMs) / 1000, + category: segment.category, + }) + } else if ( + filePath.startsWith('music/') || + filePath.startsWith('sound/') || + filePath.startsWith('dialogue/') + ) { + audios.push({ + data: base64DataUriToUint8Array(segment.assetUrl), + startTimeInMs: segment.startTimeInMs, + endTimeInMs: segment.endTimeInMs, + durationInSecs: segment.assetDurationInMs / 1000, + category: segment.category, + }) + } } } - } - ) + ) - // Combine videos and images - const videoInputs = [...videos, ...images].sort( - (a, b) => a.startTimeInMs - b.startTimeInMs - ) + // Combine videos and images + const videoInputs = [...videos, ...images].sort( + (a, b) => a.startTimeInMs - b.startTimeInMs + ) - const fullVideo = await createFullVideo( - videoInputs, - audios, - width, - height, - durationInMs, - (progress, message) => { - task.setProgress({ - message: `Rendering video (${Math.round(progress)}%)`, - value: progress * 0.9, - }) - } - ) + const fullVideo = await createFullVideo( + videoInputs, + audios, + width, + height, + durationInMs, + (progress, message) => { + task.setProgress({ + message: `Rendering video (${Math.round(progress)}%)`, + value: progress * 0.9, + }) + } + ) - const videoBlob = new Blob([fullVideo], { type: 'video/mp4' }) - saveAnyFile(videoBlob, 'my_project.mp4') - task.success() + const videoBlob = new Blob([fullVideo], { type: 'video/mp4' }) + saveAnyFile(videoBlob, 'my_project.mp4') + task.success() } catch (err) { console.error(err) task.fail(`${err || 'unknown error'}`) diff --git a/packages/timeline/src/components/slider/TimelineSlider.tsx b/packages/timeline/src/components/slider/TimelineSlider.tsx index bc91ad48..b49db488 100644 --- a/packages/timeline/src/components/slider/TimelineSlider.tsx +++ b/packages/timeline/src/components/slider/TimelineSlider.tsx @@ -77,6 +77,8 @@ const TimelineSlider: React.FC = ({ const [windowEnd, setWindowEnd] = useState(slidingWindowRangeThumbEndTimeInMs); const [playbackCursor, setPlaybackCursor] = useState(currentPlaybackCursorPosition); const [showOverlay, setShowOverlay] = useState(false); + const [lastTouchX, setLastTouchX] = useState(0); + const [isTouchDevice, setIsTouchDevice] = useState(false); const drawEvents = useCallback(() => { const canvas = canvasRef.current; @@ -108,15 +110,38 @@ const TimelineSlider: React.FC = ({ const memoizedEvents = useMemo(() => events, [events]); - const handleMouseDown = (e: React.MouseEvent) => { + const setCanvasSize = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const { width, height } = container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(dpr, dpr); + } + }, []); + + useEffect(() => { + setCanvasSize(); + drawEvents(); + }, [setCanvasSize, drawEvents, memoizedEvents, windowStart, windowEnd, playbackCursor]); + + const handleStart = useCallback((clientX: number) => { const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) { return; } - - if (useTimeline.getState().tracks.length) { return } + if (!rect) return; + if (!useTimeline.getState().tracks.length) return; - const x = e.clientX - rect.left; + const x = clientX - rect.left; const cursorX = ((playbackCursor - minTimeInMs) / (maxTimeInMs - minTimeInMs)) * rect.width; - + if (Math.abs(x - cursorX) <= playbackCursorPositionWidthInPx * 2 && allowPlaybackCursorToBeDragged) { setIsDraggingCursor(true); } else { @@ -125,7 +150,7 @@ const TimelineSlider: React.FC = ({ const windowWidth = windowEnd - windowStart; let newStart = clickedTime - windowWidth / 2; let newEnd = clickedTime + windowWidth / 2; - + if (newStart < minTimeInMs) { newStart = minTimeInMs; newEnd = newStart + windowWidth; @@ -133,48 +158,28 @@ const TimelineSlider: React.FC = ({ newEnd = maxTimeInMs; newStart = newEnd - windowWidth; } - + setWindowStart(newStart); setWindowEnd(newEnd); onSlidingWindowRangeThumbUpdate({ slidingWindowRangeThumbStartTimeInMs: newStart, slidingWindowRangeThumbEndTimeInMs: newEnd }); } - - setDragStartX(e.clientX); - setShowOverlay(true); - }; - - - const setCanvasSize = useCallback(() => { - const canvas = canvasRef.current; - const container = containerRef.current; - if (!canvas || !container) return; - - const { width, height } = container.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - - canvas.width = width * dpr; - canvas.height = height * dpr; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(dpr, dpr); - } - }, []); - const handleMouseMove = useCallback((e: MouseEvent) => { + setDragStartX(clientX); + setLastTouchX(clientX); + setShowOverlay(true); + }, [allowPlaybackCursorToBeDragged, maxTimeInMs, minTimeInMs, onSlidingWindowRangeThumbUpdate, playbackCursor, playbackCursorPositionWidthInPx, windowEnd, windowStart]); + const handleMove = useCallback((clientX: number) => { if (!isDraggingWindow && !isDraggingCursor) return; - if (useTimeline.getState().tracks.length) { return } + if (!useTimeline.getState().tracks.length) return; const containerWidth = containerRef.current?.clientWidth || 1; - const deltaX = e.clientX - dragStartX; - + const deltaX = clientX - (isTouchDevice ? lastTouchX : dragStartX); + if (isDraggingWindow) { const windowWidth = windowEnd - windowStart; let newStart = windowStart + (deltaX / containerWidth) * (maxTimeInMs - minTimeInMs); let newEnd = newStart + windowWidth; - + if (newStart < minTimeInMs) { newStart = minTimeInMs; newEnd = newStart + windowWidth; @@ -182,7 +187,7 @@ const TimelineSlider: React.FC = ({ newEnd = maxTimeInMs; newStart = newEnd - windowWidth; } - + setWindowStart(newStart); setWindowEnd(newEnd); onSlidingWindowRangeThumbUpdate({ slidingWindowRangeThumbStartTimeInMs: newStart, slidingWindowRangeThumbEndTimeInMs: newEnd }); @@ -192,37 +197,79 @@ const TimelineSlider: React.FC = ({ setPlaybackCursor(newCursor); onPlaybackCursorUpdate({ playbackCursorPositionInMs: newCursor }); } - - setDragStartX(e.clientX); - }, [isDraggingWindow, isDraggingCursor, dragStartX, windowStart, windowEnd, playbackCursor, minTimeInMs, maxTimeInMs, onSlidingWindowRangeThumbUpdate, onPlaybackCursorUpdate]); - - - useEffect(() => { - setCanvasSize(); - drawEvents(); - }, [setCanvasSize, drawEvents, memoizedEvents, windowStart, windowEnd, playbackCursor]); - const handleMouseUp = useCallback(() => { + if (isTouchDevice) { + setLastTouchX(clientX); + } else { + setDragStartX(clientX); + } + }, [dragStartX, isDraggingCursor, isDraggingWindow, isTouchDevice, lastTouchX, maxTimeInMs, minTimeInMs, onPlaybackCursorUpdate, onSlidingWindowRangeThumbUpdate, playbackCursor, windowEnd, windowStart]); + + const handleEnd = useCallback(() => { setIsDraggingWindow(false); setIsDraggingCursor(false); setShowOverlay(false); + setLastTouchX(0); }, []); - useEffect(() => { - if (showOverlay) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } else { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); + // Mouse event handlers + const handleMouseDown = (e: React.MouseEvent) => { + setIsTouchDevice(false); + handleStart(e.clientX); + }; + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isTouchDevice) { + handleMove(e.clientX); } + }, [handleMove, isTouchDevice]); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [showOverlay, handleMouseMove, handleMouseUp]); + const handleMouseUp = useCallback(() => { + if (!isTouchDevice) { + handleEnd(); + } + }, [handleEnd, isTouchDevice]); +// Touch event handlers +const handleTouchStart = (e: React.TouchEvent) => { + setIsTouchDevice(true); + handleStart(e.touches[0].clientX); +}; + +const handleTouchMove = useCallback((e: TouchEvent) => { + if (isTouchDevice) { + e.preventDefault(); // Prevent scrolling + handleMove(e.touches[0].clientX); + } +}, [handleMove, isTouchDevice]); + +const handleTouchEnd = useCallback(() => { + if (isTouchDevice) { + handleEnd(); + } +}, [handleEnd, isTouchDevice]); + + +useEffect(() => { + if (showOverlay) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + } else { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + }; +}, [showOverlay, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]); // Update state when props change useEffect(() => { @@ -236,7 +283,7 @@ const TimelineSlider: React.FC = ({ const handleWheel = (e: React.WheelEvent) => { if (!allowSlidingWindowRangeThumbResizeOnMouseWheel) return; - if (useTimeline.getState().tracks.length) { return } + if (!useTimeline.getState().tracks.length) { return } const delta = e.deltaY * mouseWheelSensibility; const windowWidth = windowEnd - windowStart; @@ -261,7 +308,8 @@ const TimelineSlider: React.FC = ({ const handleDoubleClick = (e: React.MouseEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; - if (useTimeline.getState().tracks.length) { return } + + if (!useTimeline.getState().tracks.length) { return } const x = e.clientX - rect.left; const clickedTime = minTimeInMs + (x / rect.width) * (maxTimeInMs - minTimeInMs); @@ -278,7 +326,7 @@ const TimelineSlider: React.FC = ({ newEnd = maxTimeInMs; newStart = newEnd - windowWidth; } - + setWindowStart(newStart); setWindowEnd(newEnd); onSlidingWindowRangeThumbUpdate({ slidingWindowRangeThumbStartTimeInMs: newStart, slidingWindowRangeThumbEndTimeInMs: newEnd }); @@ -291,12 +339,13 @@ const TimelineSlider: React.FC = ({ return (