Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 72 additions & 35 deletions src/app/api/video-export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ export async function POST(request: NextRequest) {
console.log(`📊 Project: ${project.title}, tracks: ${tracks?.length || 0}`);

// Check if there's content
const hasContent = tracks?.length > 0 &&
const hasContent = tracks?.length > 0 &&
Object.values(keyframes || {}).some((kfs: any) => kfs?.length > 0);

if (!hasContent) {
return NextResponse.json({
success: true,
Expand Down Expand Up @@ -77,26 +77,26 @@ export async function POST(request: NextRequest) {

// Convert relative URLs to absolute file paths for Remotion
const processedKeyframes: Record<string, any[]> = {};

for (const [trackId, kfs] of Object.entries(keyframes || {})) {
processedKeyframes[trackId] = (kfs as any[]).map((kf: any) => {
let url = kf.data?.url || kf.url;

// Skip blob URLs - they won't work in server-side rendering
if (url?.startsWith('blob:')) {
console.warn(`⚠️ Skipping blob URL for keyframe ${kf.id}`);
return { ...kf, data: { ...kf.data, url: null } };
}

// Convert relative URLs to absolute http:// URLs using localhost
if (url && !url.startsWith('http')) {
// Ensure URL starts with /
const urlPath = url.startsWith('/') ? url : `/${url}`;
url = `http://localhost:3000${urlPath}`;
}

console.log(`📁 URL for keyframe ${kf.id}: ${url}`);

return {
...kf,
data: { ...kf.data, url },
Expand All @@ -117,7 +117,7 @@ export async function POST(request: NextRequest) {
// Use project's width and height if available, otherwise calculate from aspect ratio
let outputWidth = project.width;
let outputHeight = project.height;

if (!outputWidth || !outputHeight) {
// Fallback to aspect ratio calculation
switch (project.aspectRatio) {
Expand All @@ -138,7 +138,7 @@ export async function POST(request: NextRequest) {
outputHeight = 1080;
}
}

console.log(`📐 Output resolution: ${outputWidth}x${outputHeight} (${project.aspectRatio})`);

// Select composition with explicit dimensions
Expand All @@ -147,43 +147,65 @@ export async function POST(request: NextRequest) {
id: 'MainComposition',
inputProps,
});

// Override composition dimensions with project settings
composition.width = outputWidth;
composition.height = outputHeight;

// Calculate duration based on content (latest keyframe end time)
let maxDurationMs = 0;
for (const [trackId, kfs] of Object.entries(processedKeyframes)) {
for (const kf of kfs as any[]) {
// Use visual duration (kf.duration) not original data duration
const duration = kf.duration || 0;
const endTime = (kf.timestamp || 0) + duration;
if (endTime > maxDurationMs) {
maxDurationMs = endTime;
}
}
}

// Convert to frames (30 FPS default)
const fps = composition.fps || 30;
const durationInFrames = Math.max(Math.ceil((maxDurationMs / 1000) * fps), 1);
composition.durationInFrames = durationInFrames;

console.log(`⏱️ Auto-calculated duration: ${maxDurationMs}ms (${durationInFrames} frames)`);

console.log('🎥 Rendering video...');

// Collect audio files for later merging with volume info
// Audio keyframes have their own timestamps, which are used directly for synchronization
const audioFiles: { url: string; startTime: number; volume: number }[] = [];
const audioFiles: { url: string; startTime: number; duration: number; volume: number }[] = [];
for (const [trackId, kfs] of Object.entries(processedKeyframes)) {
const track = tracks.find((t: any) => t.id === trackId);
if (track?.type === 'music' || track?.type === 'voiceover') {
// Skip if track is muted
if (track.muted) continue;

const trackVolume = track.volume ?? 100;

for (const kf of kfs as any[]) {
if (kf.data?.url) {
// Use keyframe volume if set, otherwise use track volume
const keyframeVolume = kf.data.volume ?? null;
const finalVolume = keyframeVolume !== null ? keyframeVolume : trackVolume;

// Use the audio keyframe's own timestamp directly
// This ensures audio starts at the correct time relative to the video timeline
const audioStartTime = kf.timestamp / 1000; // Convert milliseconds to seconds

const audioDuration = kf.duration / 1000; // keyframe duration in seconds

audioFiles.push({
url: kf.data.url,
startTime: audioStartTime, // Audio keyframe's timestamp in seconds
duration: audioDuration, // Audio duration (trimmed)
volume: finalVolume / 100, // Convert to 0-2 range (100% = 1.0)
});
}
}
}
}
}

// Render the video (without audio from AudioKeyFrame since it uses HTML5 Audio)
await renderMedia({
Expand All @@ -196,51 +218,66 @@ export async function POST(request: NextRequest) {

console.log('✅ Video rendered to:', outputPath);

// If there are audio files, merge them with FFmpeg
if (audioFiles.length > 0) {
// If there are audio files, merge them with FFmpeg
if (audioFiles.length > 0) {

const { execSync } = await import('child_process');
const { execSync } = await import('child_process');
const tempVideoPath = outputPath.replace(`.${format}`, `_temp.${format}`);

// Rename original video to temp
const fs = await import('fs/promises');
await fs.rename(outputPath, tempVideoPath);

try {
// Convert URLs to file paths
const audioPaths = audioFiles.map(af => {
const audioPath = af.url.startsWith('http://localhost:3000/')
? path.join(publicDir, af.url.replace('http://localhost:3000/', ''))
: af.url;
return { path: audioPath, startTime: af.startTime, volume: af.volume };
});
return { path: audioPath, startTime: af.startTime, duration: af.duration, volume: af.volume };
});

if (audioPaths.length === 1) {
// Single audio file - merge with volume and delay
const ap = audioPaths[0];
const delayMs = Math.round(ap.startTime * 1000);
// Apply delay and volume using filter_complex
const filterComplex = delayMs > 0 || ap.volume !== 1.0
? `-filter_complex "[1:a]volume=${ap.volume}${delayMs > 0 ? `,adelay=${delayMs}|${delayMs}` : ''}[aout]"`
: '';
const mapAudio = delayMs > 0 || ap.volume !== 1.0 ? '-map "[aout]"' : '-map 1:a:0';
const ffmpegCmd = `ffmpeg -y -i "${tempVideoPath}" -i "${ap.path}" ${filterComplex} -c:v copy -c:a aac -map 0:v:0 ${mapAudio} -shortest "${outputPath}"`;

// Build filter chain: trim -> reset timestamps -> volume -> delay
const filters = [];
if (ap.duration) filters.push(`atrim=duration=${ap.duration}`);
filters.push('asetpts=PTS-STARTPTS');
if (ap.volume !== 1.0) filters.push(`volume=${ap.volume}`);
if (delayMs > 0) filters.push(`adelay=${delayMs}|${delayMs}`);

const filterString = filters.join(',');
const filterComplex = `-filter_complex "[1:a]${filterString}[aout]"`;

const ffmpegCmd = `ffmpeg -y -i "${tempVideoPath}" -i "${ap.path}" ${filterComplex} -c:v copy -c:a aac -map 0:v:0 -map "[aout]" -shortest "${outputPath}"`;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The FFmpeg command is constructed using unsanitized input from the keyframes object (specifically audio URLs, durations, and volumes) and executed via execSync. This allows an attacker to perform command injection by including shell metacharacters in the input.

Remediation: Use child_process.spawn with an array of arguments instead of execSync with a command string to avoid shell execution. Alternatively, use a library like fluent-ffmpeg and strictly validate all user-supplied inputs.

execSync(ffmpegCmd, { stdio: 'pipe' });
} else {
// Multiple audio files - mix them together with individual volumes
// Build FFmpeg filter complex to mix all audio tracks with volume control
const inputs = audioPaths.map((ap, i) => `-i "${ap.path}"`).join(' ');
// Apply volume and delay to each audio track
const volumeAndDelays = audioPaths.map((ap, i) =>
`[${i + 1}:a]volume=${ap.volume},adelay=${Math.round(ap.startTime * 1000)}|${Math.round(ap.startTime * 1000)}[a${i}]`
).join(';');

// Apply trim, reset pts, volume, and delay to each audio track
const processedInputs = audioPaths.map((ap, i) => {
const filters = [];
if (ap.duration) filters.push(`atrim=duration=${ap.duration}`);
filters.push('asetpts=PTS-STARTPTS');
filters.push(`volume=${ap.volume}`);
const delayMs = Math.round(ap.startTime * 1000);
filters.push(`adelay=${delayMs}|${delayMs}`);

return `[${i + 1}:a]${filters.join(',')}[a${i}]`;
}).join(';');

const mixInputs = audioPaths.map((_, i) => `[a${i}]`).join('');
const filterComplex = `${volumeAndDelays};${mixInputs}amix=inputs=${audioPaths.length}:duration=longest[aout]`;
const filterComplex = `${processedInputs};${mixInputs}amix=inputs=${audioPaths.length}:duration=longest[aout]`;

const ffmpegCmd = `ffmpeg -y -i "${tempVideoPath}" ${inputs} -filter_complex "${filterComplex}" -map 0:v:0 -map "[aout]" -c:v copy -c:a aac "${outputPath}"`;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Similar to the previous finding, this FFmpeg command is also constructed using unsanitized user input and executed via execSync, leading to a command injection vulnerability.

Remediation: Use child_process.spawn with an array of arguments and strictly validate all user-supplied inputs.

execSync(ffmpegCmd, { stdio: 'pipe' });
}

// Clean up temp file
await fs.unlink(tempVideoPath);
console.log('✅ Audio merged successfully');
Expand Down
54 changes: 52 additions & 2 deletions src/components/video-editor/TimelineControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ import {
SkipForward,
ChevronsLeft,
ChevronsRight,
Plus,
Video,
Music,
Mic,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

const MIN_ZOOM = 0.1;
const MAX_ZOOM = 10;
Expand All @@ -33,9 +43,23 @@ export const TimelineControls = React.memo(function TimelineControls({
className,
...props
}: TimelineControlsProps) {
const { player, playerState, setPlayerState, setCurrentTimestamp } = useStudio();
const { player, playerState, setPlayerState, setCurrentTimestamp, addTrack, currentProject } = useStudio();
const { t } = useI18n();

const handleAddTrack = useCallback(async (type: 'video' | 'audio') => {
if (!currentProject) return;

await addTrack({
projectId: currentProject.id,
type,
label: type === 'video' ? 'Video Track' : 'Audio Track',
locked: false,
muted: false,
order: 0, // Order is handled by backend or reducer usually, but providing 0 is safe
volume: 100
});
}, [addTrack, currentProject]);

// Format time as MM:SS.ms - memoized
const formatTime = useCallback((seconds: number): string => {
const mins = Math.floor(seconds / 60);
Expand Down Expand Up @@ -254,7 +278,33 @@ export const TimelineControls = React.memo(function TimelineControls({
</div>

{/* Empty spacer for balance */}
<div className="w-[150px]" />
{/* Track Controls */}
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-1.5 bg-zinc-800 border-zinc-700 hover:bg-zinc-700">
<Plus className="h-3.5 w-3.5" />
<span className="text-xs">{t('videoEditor.tracks.addTrack')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-zinc-900 border-zinc-800">
<DropdownMenuItem
className="cursor-pointer focus:bg-zinc-800 focus:text-white"
onClick={() => handleAddTrack('video')}
>
<Video className="mr-2 h-4 w-4" />
<span>{t('videoEditor.tracks.addVideoTrack')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer focus:bg-zinc-800 focus:text-white"
onClick={() => handleAddTrack('audio')}
>
<Music className="mr-2 h-4 w-4" />
<span>{t('videoEditor.tracks.addAudioTrack')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
});
Expand Down
43 changes: 14 additions & 29 deletions src/components/video-editor/TimelineRuler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,40 +32,25 @@ const MIN_MAJOR_SPACING_PX = 96;
const EPSILON = 0.000_01;

function formatTickLabel(seconds: number, majorInterval: number) {
if (seconds < EPSILON) {
return "0s";
if (Math.abs(seconds) < EPSILON) {
return "0:00";
}

if (seconds >= 3600) {
const hours = seconds / 3600;
const decimals = majorInterval < 3600 ? 1 : 0;
return `${hours.toFixed(decimals)}h`;
}

if (seconds >= 60) {
const minutes = seconds / 60;
const decimals = majorInterval < 60 ? 1 : 0;
return `${minutes.toFixed(decimals)}m`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;

if (seconds >= 1) {
const isNearInteger = Math.abs(seconds - Math.round(seconds)) < 0.005;
const decimals =
isNearInteger && majorInterval >= 1
? 0
: majorInterval < 1
? Math.ceil(-Math.log10(majorInterval))
: Math.min(
2,
Math.max(
1,
Math.ceil(-Math.log10(seconds - Math.floor(seconds))),
),
);
return `${seconds.toFixed(decimals)}s`;
// Decide on decimal places based on interval
// If interval is less than 1 second, we likely need decimals
if (majorInterval < 1) {
// Show decimals - e.g. 0:00.50
// Keep seconds part fixed width if possible, or just standard numeric
const s = remainingSeconds.toFixed(2).padStart(5, '0');
return `${minutes}:${s}`;
}

return `${Math.round(seconds * 1000)}ms`;
// Integer seconds - e.g. 0:10, 1:05
const s = Math.floor(remainingSeconds).toString().padStart(2, '0');
return `${minutes}:${s}`;
}

function chooseMajorInterval(pixelsPerSecond: number) {
Expand Down
Loading