fix: 3 critical bugs — audio corruption, blob URLs, static duration

1. Audio/Video sync: During playback, let the browser handle media naturally
   and only force-seek when drift > 250ms. Prevents audio glitching caused
   by setting currentTime 30x/sec. When paused/scrubbing, sync precisely.

2. Blob URLs → Server uploads: All media uploads (BrandTabMedia, SceneFieldEditor,
   TemplateFieldInput) now POST to /api/upload and use persistent server URLs
   instead of blob: URLs that vanish on refresh.

3. Dynamic duration: Added useVideoDurations hook that probes actual video
   durations from uploaded form videos. getTemplateDuration and
   compileExpressToTimeline now accept videoDurations to override static
   durationSeconds for form-sourced scenes.
This commit is contained in:
2026-06-02 09:48:28 -05:00
parent 32505f95ce
commit a21675e5fc
9 changed files with 212 additions and 37 deletions
+20 -6
View File
@@ -131,9 +131,16 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDe
compact compact
accept="audio/*" accept="audio/*"
label="Subir audio" label="Subir audio"
onFiles={(files) => { onFiles={async (files) => {
const url = URL.createObjectURL(files[0]); const formData = new FormData();
handleDesignChange('brandAudioUrl', url); formData.append('file', files[0]);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) handleDesignChange('brandAudioUrl', data.url);
} catch (err) {
console.error('Audio upload failed:', err);
}
}} }}
/> />
</div> </div>
@@ -231,9 +238,16 @@ const VideoUploadSimple: React.FC<{
compact compact
accept="video/*" accept="video/*"
label="Subir archivo" label="Subir archivo"
onFiles={(files) => { onFiles={async (files) => {
const url = URL.createObjectURL(files[0]); const formData = new FormData();
onUrlChange(url); formData.append('file', files[0]);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) onUrlChange(data.url);
} catch (err) {
console.error('Video upload failed:', err);
}
}} }}
/> />
</div> </div>
+7 -3
View File
@@ -8,6 +8,7 @@ import { StoryboardView } from './StoryboardView';
import { SceneFieldEditor } from './SceneFieldEditor'; import { SceneFieldEditor } from './SceneFieldEditor';
import { ExpressStylePanel } from './ExpressStylePanel'; import { ExpressStylePanel } from './ExpressStylePanel';
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler'; import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
import { useVideoDurations } from '../../hooks/useVideoDurations';
interface ExpressEditorProps { interface ExpressEditorProps {
designMD: DesignMD; designMD: DesignMD;
@@ -62,13 +63,16 @@ export const ExpressEditor: React.FC<ExpressEditorProps> = ({
setFieldData(prev => ({ ...prev, [fieldId]: value })); setFieldData(prev => ({ ...prev, [fieldId]: value }));
}, []); }, []);
// Probe actual video durations for form-sourced scenes
const videoDurations = useVideoDurations(selectedTemplate, fieldData);
// Compile template to timeline // Compile template to timeline
const compiled = useMemo(() => { const compiled = useMemo(() => {
if (!selectedTemplate) return null; if (!selectedTemplate) return null;
return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company); return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company, videoDurations);
}, [selectedTemplate, fieldData, designMD, company]); }, [selectedTemplate, fieldData, designMD, company, videoDurations]);
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate) : 0; const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate, videoDurations) : 0;
const fps = 30; const fps = 30;
const totalFrames = Math.max(30, totalDuration * fps); const totalFrames = Math.max(30, totalDuration * fps);
+10 -3
View File
@@ -178,11 +178,18 @@ export const SceneFieldEditor: React.FC<SceneFieldEditorProps> = ({
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*"
className="hidden" className="hidden"
onChange={(e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
const url = URL.createObjectURL(file); const formData = new FormData();
onFieldChange(field.id, url); formData.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) onFieldChange(field.id, data.url);
} catch (err) {
console.error('Media upload failed:', err);
}
} }
}} }}
/> />
+10 -3
View File
@@ -169,11 +169,18 @@ export const TemplateFieldInput: React.FC<TemplateFieldInputProps> = ({
type="file" type="file"
accept={isVideoField ? 'video/*' : 'image/*'} accept={isVideoField ? 'video/*' : 'image/*'}
className="hidden" className="hidden"
onChange={(e) => { onChange={async (e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
const url = URL.createObjectURL(file); const formData = new FormData();
onChange(url); formData.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) onChange(data.url);
} catch (err) {
console.error('Media upload failed:', err);
}
} }
}} }}
/> />
+9 -2
View File
@@ -269,8 +269,15 @@ app.whenReady().then(async () => {
return; return;
} }
// 4. Set BRADLY_SERVE_URL for the renderer to find the app // 4. Set BRADLY_SERVE_URL for the headless renderer (Puppeteer)
process.env.BRADLY_SERVE_URL = `http://127.0.0.1:${expressPort}`; if (process.env.NODE_ENV === 'development') {
// In dev, the React app is served by electron-vite's Vite dev server
const rendererUrl = process.env.ELECTRON_RENDERER_URL || 'http://localhost:5173';
process.env.BRADLY_SERVE_URL = rendererUrl;
} else {
// In prod, Express serves both API and static renderer files
process.env.BRADLY_SERVE_URL = `http://127.0.0.1:${expressPort}`;
}
// 5. Create the main window // 5. Create the main window
createWindow(); createWindow();
+32 -6
View File
@@ -3,6 +3,9 @@
* *
* An <audio> element that syncs to the player's current frame. * An <audio> element that syncs to the player's current frame.
* Supports volume as a per-frame callback function. * Supports volume as a per-frame callback function.
*
* Key design: During playback, let the browser handle audio naturally
* and only seek when drift exceeds a threshold. This prevents audio glitching.
*/ */
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { useCurrentFrame } from '../player/useCurrentFrame'; import { useCurrentFrame } from '../player/useCurrentFrame';
@@ -17,6 +20,11 @@ interface AudioProps {
endAt?: number; // frame endAt?: number; // frame
} }
/** Maximum drift (in seconds) before we force a seek during playback */
const DRIFT_THRESHOLD_PLAYING = 0.25;
/** Maximum drift when paused/seeking */
const DRIFT_THRESHOLD_PAUSED = 0.05;
export const Audio: React.FC<AudioProps> = ({ export const Audio: React.FC<AudioProps> = ({
src, src,
volume: volumeProp, volume: volumeProp,
@@ -28,31 +36,49 @@ export const Audio: React.FC<AudioProps> = ({
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
const { playing } = usePlayerState(); const { playing } = usePlayerState();
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const wasPlayingRef = useRef(false);
// Compute the audio's local time // Compute the audio's local time
const audioFrame = frame + startFrom; const audioFrame = frame + startFrom;
const targetTime = audioFrame / fps; const targetTime = audioFrame / fps;
// Sync time // Sync time — different strategies for playing vs paused
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
if (Math.abs(audio.currentTime - targetTime) > 0.1) { const drift = Math.abs(audio.currentTime - targetTime);
audio.currentTime = targetTime;
}
}, [targetTime]);
// Sync play/pause if (playing) {
// During playback: only seek if drift is significant
if (drift > DRIFT_THRESHOLD_PLAYING) {
audio.currentTime = targetTime;
}
} else {
// When paused: sync precisely
if (drift > DRIFT_THRESHOLD_PAUSED) {
audio.currentTime = targetTime;
}
}
}, [targetTime, playing]);
// Sync play/pause state
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
if (playing) { if (playing) {
// When starting playback, sync time first then play
if (!wasPlayingRef.current) {
audio.currentTime = targetTime;
}
audio.play().catch(() => {}); audio.play().catch(() => {});
} else { } else {
audio.pause(); audio.pause();
audio.currentTime = targetTime;
} }
wasPlayingRef.current = playing;
}, [playing]); }, [playing]);
// Set playback rate // Set playback rate
+35 -9
View File
@@ -8,6 +8,10 @@
* - Playback rate * - Playback rate
* - Trim (startFrom / endAt in frames) * - Trim (startFrom / endAt in frames)
* - Play/pause state from player context * - Play/pause state from player context
*
* Key design: During playback, let the browser handle audio/video naturally
* and only seek when drift exceeds a threshold. This prevents audio glitching
* caused by constantly setting currentTime on every frame.
*/ */
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { useCurrentFrame } from '../player/useCurrentFrame'; import { useCurrentFrame } from '../player/useCurrentFrame';
@@ -24,6 +28,11 @@ interface VideoProps extends Omit<React.VideoHTMLAttributes<HTMLVideoElement>, '
onError?: (e: React.SyntheticEvent<HTMLVideoElement>) => void; onError?: (e: React.SyntheticEvent<HTMLVideoElement>) => void;
} }
/** Maximum drift (in seconds) before we force a seek during playback */
const DRIFT_THRESHOLD_PLAYING = 0.25;
/** Maximum drift when paused/seeking — needs to be frame-accurate */
const DRIFT_THRESHOLD_PAUSED = 0.05;
export const Video: React.FC<VideoProps> = ({ export const Video: React.FC<VideoProps> = ({
src, src,
volume: volumeProp, volume: volumeProp,
@@ -39,34 +48,51 @@ export const Video: React.FC<VideoProps> = ({
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
const { playing } = usePlayerState(); const { playing } = usePlayerState();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const lastSyncRef = useRef<number>(-1); const wasPlayingRef = useRef(false);
// Compute the video's local time // Compute the video's local time
const videoFrame = frame + startFrom; const videoFrame = frame + startFrom;
const targetTime = videoFrame / fps; const targetTime = videoFrame / fps;
// Sync time // Sync time — different strategies for playing vs paused
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
// Only seek if significantly out of sync (>50ms) or if frame changed significantly const drift = Math.abs(video.currentTime - targetTime);
if (Math.abs(video.currentTime - targetTime) > 0.05 || lastSyncRef.current !== frame) {
video.currentTime = targetTime;
lastSyncRef.current = frame;
}
}, [targetTime, frame]);
// Sync play/pause if (playing) {
// During playback: only seek if drift is significant
// This lets the browser's audio decoder run smoothly
if (drift > DRIFT_THRESHOLD_PLAYING) {
video.currentTime = targetTime;
}
} else {
// When paused or seeking: sync precisely for scrubbing accuracy
if (drift > DRIFT_THRESHOLD_PAUSED) {
video.currentTime = targetTime;
}
}
}, [targetTime, playing]);
// Sync play/pause state
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
if (playing) { if (playing) {
// When starting playback, sync time first then play
if (!wasPlayingRef.current) {
video.currentTime = targetTime;
}
video.play().catch(() => {}); video.play().catch(() => {});
} else { } else {
video.pause(); video.pause();
// When pausing, sync to exact frame
video.currentTime = targetTime;
} }
wasPlayingRef.current = playing;
}, [playing]); }, [playing]);
// Set playback rate // Set playback rate
+66
View File
@@ -0,0 +1,66 @@
import { useState, useEffect } from 'react';
import { ExpressTemplate } from '../types';
/**
* useVideoDurations — Probes actual video durations for form-sourced scenes.
*
* When a user uploads a video to a form field, this hook creates a temporary
* <video> element to read its metadata and returns the actual duration in seconds.
*
* Returns a map of sceneId → duration (seconds) for scenes whose uploaded
* videos have been probed. Scenes without uploaded videos are not included.
*/
export function useVideoDurations(
template: ExpressTemplate | null,
fieldData: Record<string, string>,
): Record<string, number> {
const [durations, setDurations] = useState<Record<string, number>>({});
useEffect(() => {
if (!template) return;
// Find scenes that source video from form uploads
const formVideoScenes = template.scenes.filter(
s => s.segmentSource === 'form' && (s.type === 'intro' || s.type === 'outro' || s.type === 'content')
);
for (const scene of formVideoScenes) {
const fieldId = `segment-${scene.id}`;
const videoUrl = fieldData[fieldId];
if (!videoUrl || videoUrl.trim() === '') {
// No video uploaded — remove any stale duration
setDurations(prev => {
if (prev[scene.id]) {
const { [scene.id]: _, ...rest } = prev;
return rest;
}
return prev;
});
continue;
}
// Probe the video duration
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (video.duration && isFinite(video.duration)) {
setDurations(prev => ({
...prev,
[scene.id]: video.duration,
}));
}
video.remove();
};
video.onerror = () => {
video.remove();
};
video.src = videoUrl;
}
}, [template, fieldData]);
return durations;
}
+23 -5
View File
@@ -78,9 +78,23 @@ export function getAspectDimensions(aspect: string): { w: number; h: number } {
} }
} }
/** Compute total duration of template in seconds */ /**
export function getTemplateDuration(template: ExpressTemplate): number { * Compute total duration of template in seconds.
return template.scenes.reduce((sum, s) => sum + s.durationSeconds, 0); * @param videoDurations Optional map of scene.id → actual video duration (seconds).
* When provided, overrides the static durationSeconds for scenes
* that source video from form uploads.
*/
export function getTemplateDuration(
template: ExpressTemplate,
videoDurations?: Record<string, number>,
): number {
return template.scenes.reduce((sum, scene) => {
// If this scene has a form-sourced video and we know its actual duration, use it
if (videoDurations && scene.segmentSource === 'form' && videoDurations[scene.id]) {
return sum + videoDurations[scene.id];
}
return sum + scene.durationSeconds;
}, 0);
} }
/** /**
@@ -92,6 +106,7 @@ export function compileExpressToTimeline(
fieldData: Record<string, string>, fieldData: Record<string, string>,
designMD: DesignMD, designMD: DesignMD,
company?: CompanyProfile, company?: CompanyProfile,
videoDurations?: Record<string, number>,
): { elements: TimelineElement[]; layers: TimelineLayer[] } { ): { elements: TimelineElement[]; layers: TimelineLayer[] } {
const fps = 30; const fps = 30;
const elements: TimelineElement[] = []; const elements: TimelineElement[] = [];
@@ -106,8 +121,11 @@ export function compileExpressToTimeline(
// Process each scene sequentially — the template's scenes are the sole source of truth // Process each scene sequentially — the template's scenes are the sole source of truth
for (const scene of template.scenes) { for (const scene of template.scenes) {
// Use actual video duration if available for form-sourced scenes
const sceneDurFrames = scene.durationSeconds * fps; const sceneDuration = (videoDurations && scene.segmentSource === 'form' && videoDurations[scene.id])
? videoDurations[scene.id]
: scene.durationSeconds;
const sceneDurFrames = sceneDuration * fps;
const sceneStart = frameOffset; const sceneStart = frameOffset;
const sceneEnd = frameOffset + sceneDurFrames; const sceneEnd = frameOffset + sceneDurFrames;