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:
@@ -131,9 +131,16 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDe
|
||||
compact
|
||||
accept="audio/*"
|
||||
label="Subir audio"
|
||||
onFiles={(files) => {
|
||||
const url = URL.createObjectURL(files[0]);
|
||||
handleDesignChange('brandAudioUrl', url);
|
||||
onFiles={async (files) => {
|
||||
const formData = new FormData();
|
||||
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>
|
||||
@@ -231,9 +238,16 @@ const VideoUploadSimple: React.FC<{
|
||||
compact
|
||||
accept="video/*"
|
||||
label="Subir archivo"
|
||||
onFiles={(files) => {
|
||||
const url = URL.createObjectURL(files[0]);
|
||||
onUrlChange(url);
|
||||
onFiles={async (files) => {
|
||||
const formData = new FormData();
|
||||
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>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StoryboardView } from './StoryboardView';
|
||||
import { SceneFieldEditor } from './SceneFieldEditor';
|
||||
import { ExpressStylePanel } from './ExpressStylePanel';
|
||||
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
|
||||
import { useVideoDurations } from '../../hooks/useVideoDurations';
|
||||
|
||||
interface ExpressEditorProps {
|
||||
designMD: DesignMD;
|
||||
@@ -62,13 +63,16 @@ export const ExpressEditor: React.FC<ExpressEditorProps> = ({
|
||||
setFieldData(prev => ({ ...prev, [fieldId]: value }));
|
||||
}, []);
|
||||
|
||||
// Probe actual video durations for form-sourced scenes
|
||||
const videoDurations = useVideoDurations(selectedTemplate, fieldData);
|
||||
|
||||
// Compile template to timeline
|
||||
const compiled = useMemo(() => {
|
||||
if (!selectedTemplate) return null;
|
||||
return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company);
|
||||
}, [selectedTemplate, fieldData, designMD, company]);
|
||||
return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company, videoDurations);
|
||||
}, [selectedTemplate, fieldData, designMD, company, videoDurations]);
|
||||
|
||||
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate) : 0;
|
||||
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate, videoDurations) : 0;
|
||||
const fps = 30;
|
||||
const totalFrames = Math.max(30, totalDuration * fps);
|
||||
|
||||
|
||||
@@ -178,11 +178,18 @@ export const SceneFieldEditor: React.FC<SceneFieldEditorProps> = ({
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
onFieldChange(field.id, url);
|
||||
const formData = new FormData();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -169,11 +169,18 @@ export const TemplateFieldInput: React.FC<TemplateFieldInputProps> = ({
|
||||
type="file"
|
||||
accept={isVideoField ? 'video/*' : 'image/*'}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
onChange(url);
|
||||
const formData = new FormData();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -269,8 +269,15 @@ app.whenReady().then(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Set BRADLY_SERVE_URL for the renderer to find the app
|
||||
process.env.BRADLY_SERVE_URL = `http://127.0.0.1:${expressPort}`;
|
||||
// 4. Set BRADLY_SERVE_URL for the headless renderer (Puppeteer)
|
||||
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
|
||||
createWindow();
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* An <audio> element that syncs to the player's current frame.
|
||||
* 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 { useCurrentFrame } from '../player/useCurrentFrame';
|
||||
@@ -17,6 +20,11 @@ interface AudioProps {
|
||||
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> = ({
|
||||
src,
|
||||
volume: volumeProp,
|
||||
@@ -28,31 +36,49 @@ export const Audio: React.FC<AudioProps> = ({
|
||||
const { fps } = useVideoConfig();
|
||||
const { playing } = usePlayerState();
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
// Compute the audio's local time
|
||||
const audioFrame = frame + startFrom;
|
||||
const targetTime = audioFrame / fps;
|
||||
|
||||
// Sync time
|
||||
// Sync time — different strategies for playing vs paused
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (Math.abs(audio.currentTime - targetTime) > 0.1) {
|
||||
audio.currentTime = targetTime;
|
||||
}
|
||||
}, [targetTime]);
|
||||
const drift = Math.abs(audio.currentTime - 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(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (playing) {
|
||||
// When starting playback, sync time first then play
|
||||
if (!wasPlayingRef.current) {
|
||||
audio.currentTime = targetTime;
|
||||
}
|
||||
audio.play().catch(() => {});
|
||||
} else {
|
||||
audio.pause();
|
||||
audio.currentTime = targetTime;
|
||||
}
|
||||
|
||||
wasPlayingRef.current = playing;
|
||||
}, [playing]);
|
||||
|
||||
// Set playback rate
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* - Playback rate
|
||||
* - Trim (startFrom / endAt in frames)
|
||||
* - 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 { useCurrentFrame } from '../player/useCurrentFrame';
|
||||
@@ -24,6 +28,11 @@ interface VideoProps extends Omit<React.VideoHTMLAttributes<HTMLVideoElement>, '
|
||||
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> = ({
|
||||
src,
|
||||
volume: volumeProp,
|
||||
@@ -39,34 +48,51 @@ export const Video: React.FC<VideoProps> = ({
|
||||
const { fps } = useVideoConfig();
|
||||
const { playing } = usePlayerState();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const lastSyncRef = useRef<number>(-1);
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
// Compute the video's local time
|
||||
const videoFrame = frame + startFrom;
|
||||
const targetTime = videoFrame / fps;
|
||||
|
||||
// Sync time
|
||||
// Sync time — different strategies for playing vs paused
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Only seek if significantly out of sync (>50ms) or if frame changed significantly
|
||||
if (Math.abs(video.currentTime - targetTime) > 0.05 || lastSyncRef.current !== frame) {
|
||||
video.currentTime = targetTime;
|
||||
lastSyncRef.current = frame;
|
||||
}
|
||||
}, [targetTime, frame]);
|
||||
const drift = Math.abs(video.currentTime - targetTime);
|
||||
|
||||
// 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(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (playing) {
|
||||
// When starting playback, sync time first then play
|
||||
if (!wasPlayingRef.current) {
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
video.pause();
|
||||
// When pausing, sync to exact frame
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
|
||||
wasPlayingRef.current = playing;
|
||||
}, [playing]);
|
||||
|
||||
// Set playback rate
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
return template.scenes.reduce((sum, s) => sum + s.durationSeconds, 0);
|
||||
/**
|
||||
* Compute total duration of template in seconds.
|
||||
* @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>,
|
||||
designMD: DesignMD,
|
||||
company?: CompanyProfile,
|
||||
videoDurations?: Record<string, number>,
|
||||
): { elements: TimelineElement[]; layers: TimelineLayer[] } {
|
||||
const fps = 30;
|
||||
const elements: TimelineElement[] = [];
|
||||
@@ -106,8 +121,11 @@ export function compileExpressToTimeline(
|
||||
|
||||
// Process each scene sequentially — the template's scenes are the sole source of truth
|
||||
for (const scene of template.scenes) {
|
||||
|
||||
const sceneDurFrames = scene.durationSeconds * fps;
|
||||
// Use actual video duration if available for form-sourced scenes
|
||||
const sceneDuration = (videoDurations && scene.segmentSource === 'form' && videoDurations[scene.id])
|
||||
? videoDurations[scene.id]
|
||||
: scene.durationSeconds;
|
||||
const sceneDurFrames = sceneDuration * fps;
|
||||
const sceneStart = frameOffset;
|
||||
const sceneEnd = frameOffset + sceneDurFrames;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user