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:
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user