fix: dynamic video duration across ProductionForm + LivePreviewCanvas

- Expanded useVideoDurations to detect both form-sourced segments AND
  editable-slot video fields inside content scenes
- Wired videoDurations into ProductionForm (getTemplateDuration,
  compileExpressToTimeline, LivePreviewCanvas, ExportModal)
- LivePreviewCanvas now accepts videoDurations prop
- Simplified getTemplateDuration/compiler: any scene with a known
  video duration uses it, regardless of segmentSource type
- A 33s uploaded video now creates a 33s timeline, not 5s
This commit is contained in:
2026-06-02 09:58:37 -05:00
parent a21675e5fc
commit 560a413c1e
4 changed files with 73 additions and 36 deletions
+8 -3
View File
@@ -14,6 +14,7 @@ import { TemplateFieldInput } from '../shared/TemplateFieldInput';
import { LivePreviewCanvas } from '../shared/LivePreviewCanvas'; import { LivePreviewCanvas } from '../shared/LivePreviewCanvas';
import { migrateExpressFields } from '../../context/TemplateBuilderContext'; import { migrateExpressFields } from '../../context/TemplateBuilderContext';
import { useBatchProduction } from '../../hooks/useBatchProduction'; import { useBatchProduction } from '../../hooks/useBatchProduction';
import { useVideoDurations } from '../../hooks/useVideoDurations';
import { BatchDataPanel } from './BatchDataPanel'; import { BatchDataPanel } from './BatchDataPanel';
import { exportBatchAsZip, BatchExportProgress } from '../../utils/batchExporter'; import { exportBatchAsZip, BatchExportProgress } from '../../utils/batchExporter';
@@ -97,16 +98,19 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
const playerRef = useRef<BradlyPlayerRef>(null); const playerRef = useRef<BradlyPlayerRef>(null);
// Probe actual video durations for dynamic timeline
const videoDurations = useVideoDurations(template, fieldData);
const designMD = brand.design; const designMD = brand.design;
const fps = 30; const fps = 30;
const totalDuration = getTemplateDuration(template); const totalDuration = getTemplateDuration(template, videoDurations);
const totalFrames = Math.max(30, totalDuration * fps); const totalFrames = Math.max(30, totalDuration * fps);
// ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ─── // ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ───
const compiled = useMemo( const compiled = useMemo(
() => { () => {
if (!showExportModal) return { elements: [], layers: [] }; if (!showExportModal) return { elements: [], layers: [] };
const result = compileExpressToTimeline(template, fieldData, designMD, brand); const result = compileExpressToTimeline(template, fieldData, designMD, brand, videoDurations);
result.elements = result.elements.map(el => { result.elements = result.elements.map(el => {
const fieldId = el.sourceFieldId; const fieldId = el.sourceFieldId;
const fitOverride = fieldId ? mediaFits[fieldId] : undefined; const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
@@ -121,7 +125,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
}); });
return result; return result;
}, },
[showExportModal, template, fieldData, designMD, brand, mediaFits, containBgColors] [showExportModal, template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations]
); );
// ─── Collect all TemplateFields across all scenes ─── // ─── Collect all TemplateFields across all scenes ───
@@ -600,6 +604,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
activeSceneId={activeSceneId} activeSceneId={activeSceneId}
onSceneChange={setActiveSceneId} onSceneChange={setActiveSceneId}
playerRef={playerRef} playerRef={playerRef}
videoDurations={videoDurations}
statusLabel={ statusLabel={
batch.isBatchMode batch.isBatchMode
? (batch.pieceCount > 0 ? `Pieza ${activeBatchPieceIndex + 1} de ${batch.pieceCount}` : 'Sin piezas') ? (batch.pieceCount > 0 ? `Pieza ${activeBatchPieceIndex + 1} de ${batch.pieceCount}` : 'Sin piezas')
+6 -3
View File
@@ -36,6 +36,8 @@ export interface LivePreviewCanvasProps {
statusLabel?: string; statusLabel?: string;
/** Whether all required fields are complete */ /** Whether all required fields are complete */
isComplete?: boolean; isComplete?: boolean;
/** Video duration overrides per scene (from useVideoDurations) */
videoDurations?: Record<string, number>;
} }
/** Format frame number to mm:ss */ /** Format frame number to mm:ss */
@@ -66,6 +68,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
playerRef: externalRef, playerRef: externalRef,
statusLabel, statusLabel,
isComplete = false, isComplete = false,
videoDurations,
}) => { }) => {
const internalRef = useRef<BradlyPlayerRef>(null); const internalRef = useRef<BradlyPlayerRef>(null);
const playerRef = externalRef || internalRef; const playerRef = externalRef || internalRef;
@@ -76,13 +79,13 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
const isScrubbing = useRef(false); const isScrubbing = useRef(false);
const fps = 30; const fps = 30;
const totalDuration = getTemplateDuration(template); const totalDuration = getTemplateDuration(template, videoDurations);
const totalFrames = Math.max(30, totalDuration * fps); const totalFrames = Math.max(30, totalDuration * fps);
const dimensions = getAspectDimensions(template.aspectRatio); const dimensions = getAspectDimensions(template.aspectRatio);
// Compile template to timeline (reactive to fieldData + mediaFits) // Compile template to timeline (reactive to fieldData + mediaFits)
const compiled = useMemo(() => { const compiled = useMemo(() => {
const result = compileExpressToTimeline(template, fieldData, designMD, brand); const result = compileExpressToTimeline(template, fieldData, designMD, brand, videoDurations);
// Strip transitions and apply mediaFit overrides // Strip transitions and apply mediaFit overrides
result.elements = result.elements.map(el => { result.elements = result.elements.map(el => {
const fieldId = el.sourceFieldId; const fieldId = el.sourceFieldId;
@@ -97,7 +100,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
}; };
}); });
return result; return result;
}, [template, fieldData, designMD, brand, mediaFits, containBgColors]); }, [template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations]);
const playerInputProps = useMemo(() => ({ const playerInputProps = useMemo(() => ({
designMD, designMD,
+54 -25
View File
@@ -2,13 +2,18 @@ import { useState, useEffect } from 'react';
import { ExpressTemplate } from '../types'; import { ExpressTemplate } from '../types';
/** /**
* useVideoDurations — Probes actual video durations for form-sourced scenes. * useVideoDurations — Probes actual video durations for scenes that contain
* user-uploaded videos.
* *
* When a user uploads a video to a form field, this hook creates a temporary * Detects two kinds of video sources:
* <video> element to read its metadata and returns the actual duration in seconds. * 1. Form-sourced segments (intro/outro with segmentSource='form')
* → fieldData key: "segment-{scene.id}"
* 2. Editable-slot video fields inside content scenes
* → fieldData key: field.id
* *
* Returns a map of sceneId → duration (seconds) for scenes whose uploaded * Returns a map of sceneId → duration (seconds). When a video is uploaded,
* videos have been probed. Scenes without uploaded videos are not included. * its actual duration overrides the static scene.durationSeconds in
* getTemplateDuration and compileExpressToTimeline.
*/ */
export function useVideoDurations( export function useVideoDurations(
template: ExpressTemplate | null, template: ExpressTemplate | null,
@@ -19,28 +24,52 @@ export function useVideoDurations(
useEffect(() => { useEffect(() => {
if (!template) return; if (!template) return;
// Find scenes that source video from form uploads // Collect all (sceneId, videoUrl) pairs to probe
const formVideoScenes = template.scenes.filter( const toProbe: { sceneId: string; videoUrl: string }[] = [];
s => s.segmentSource === 'form' && (s.type === 'intro' || s.type === 'outro' || s.type === 'content')
);
for (const scene of formVideoScenes) { for (const scene of template.scenes) {
const fieldId = `segment-${scene.id}`; // 1. Form-sourced segments (intro/outro)
const videoUrl = fieldData[fieldId]; if (scene.segmentSource === 'form') {
const fieldId = `segment-${scene.id}`;
if (!videoUrl || videoUrl.trim() === '') { const videoUrl = fieldData[fieldId];
// No video uploaded — remove any stale duration if (videoUrl && videoUrl.trim()) {
setDurations(prev => { toProbe.push({ sceneId: scene.id, videoUrl });
if (prev[scene.id]) { continue;
const { [scene.id]: _, ...rest } = prev; }
return rest;
}
return prev;
});
continue;
} }
// Probe the video duration // 2. Editable-slot video fields inside any scene
const fields = scene.fields ?? [];
for (const field of fields) {
if (
field.type === 'video' &&
field.nature === 'editable-slot'
) {
const videoUrl = fieldData[field.id];
if (videoUrl && videoUrl.trim()) {
toProbe.push({ sceneId: scene.id, videoUrl });
break; // One video per scene is enough to set the duration
}
}
}
}
// Clear stale durations for scenes that no longer have a video
const activeSceneIds = new Set(toProbe.map(p => p.sceneId));
setDurations(prev => {
const cleaned = { ...prev };
let changed = false;
for (const key of Object.keys(cleaned)) {
if (!activeSceneIds.has(key)) {
delete cleaned[key];
changed = true;
}
}
return changed ? cleaned : prev;
});
// Probe each video
for (const { sceneId, videoUrl } of toProbe) {
const video = document.createElement('video'); const video = document.createElement('video');
video.preload = 'metadata'; video.preload = 'metadata';
@@ -48,7 +77,7 @@ export function useVideoDurations(
if (video.duration && isFinite(video.duration)) { if (video.duration && isFinite(video.duration)) {
setDurations(prev => ({ setDurations(prev => ({
...prev, ...prev,
[scene.id]: video.duration, [sceneId]: video.duration,
})); }));
} }
video.remove(); video.remove();
+5 -5
View File
@@ -82,15 +82,15 @@ export function getAspectDimensions(aspect: string): { w: number; h: number } {
* Compute total duration of template in seconds. * Compute total duration of template in seconds.
* @param videoDurations Optional map of scene.id → actual video duration (seconds). * @param videoDurations Optional map of scene.id → actual video duration (seconds).
* When provided, overrides the static durationSeconds for scenes * When provided, overrides the static durationSeconds for scenes
* that source video from form uploads. * that have user-uploaded video content.
*/ */
export function getTemplateDuration( export function getTemplateDuration(
template: ExpressTemplate, template: ExpressTemplate,
videoDurations?: Record<string, number>, videoDurations?: Record<string, number>,
): number { ): number {
return template.scenes.reduce((sum, scene) => { return template.scenes.reduce((sum, scene) => {
// If this scene has a form-sourced video and we know its actual duration, use it // If we know the actual video duration for this scene, use it
if (videoDurations && scene.segmentSource === 'form' && videoDurations[scene.id]) { if (videoDurations && videoDurations[scene.id]) {
return sum + videoDurations[scene.id]; return sum + videoDurations[scene.id];
} }
return sum + scene.durationSeconds; return sum + scene.durationSeconds;
@@ -121,8 +121,8 @@ 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 // Use actual video duration if available (from useVideoDurations)
const sceneDuration = (videoDurations && scene.segmentSource === 'form' && videoDurations[scene.id]) const sceneDuration = (videoDurations && videoDurations[scene.id])
? videoDurations[scene.id] ? videoDurations[scene.id]
: scene.durationSeconds; : scene.durationSeconds;
const sceneDurFrames = sceneDuration * fps; const sceneDurFrames = sceneDuration * fps;