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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions src/app/api/transcribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import OpenAI from "openai";

// Create OpenAI client
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});

export async function POST(req: Request) {
try {
// Get uploaded file from request
const formData = await req.formData();
const file = formData.get("file") as File | null;

if (!file) {
return NextResponse.json(
{ error: "No file uploaded" },
{ status: 400 }
);
}

// Send file to Whisper API and request segment timestamps
const transcription = await openai.audio.transcriptions.create({
file,
model: "whisper-1",
response_format: "verbose_json",
});

// Return transcript text and timestamps if the Whisper response provides them
return NextResponse.json({
text: transcription.text,
segments: (transcription as any).segments ?? null,
});

} catch (error: any) {
console.error("FULL ERROR:", error);

return NextResponse.json(
{
error: error?.message || "Failed to transcribe audio",
},
{ status: 500 }
);
}
}
126 changes: 107 additions & 19 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useRef, useEffect, useMemo } from "react";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { useVideoEditor } from "@/hooks/useVideoEditor";
import { TextOverlay } from "@/lib/types";
import FileUpload from "./FileUpload";
Expand Down Expand Up @@ -199,33 +199,102 @@ function KeyboardShortcutsPanel() {

export default function VideoEditor() {
const {
file, duration, recipe, status, progress,
result, error, exportStartedAt, updateRecipe,
handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings,
file,
duration,
recipe,
addSubtitle,
generateSubtitles, // ✅ ADD THIS HERE
status,
progress,
result,
error,
exportStartedAt,
updateRecipe,
handleFileSelect,
fileError,
handleExport,
cancelExport,
reset,
resetSettings,
videoRef,
seekTo,
overlayFile, setOverlayFile,
overlayPosition, setOverlayPosition,
overlaySize, setOverlaySize,
overlayOpacity, setOverlayOpacity,
overlayFile,
setOverlayFile,
overlayPosition,
setOverlayPosition,
overlaySize,
setOverlaySize,
overlayOpacity,
setOverlayOpacity,
recommendedPreset,
currentTime,
toggleSound,
} = useVideoEditor();

useKeyboardShortcuts({
file,
recipe,
resetSettings,
updateRecipe,
handleExport,
status,
cancelExport,
onToggleShortcutsModal: () => {},
});

const [copied, setCopied] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);

const handleGenerateSubtitles = useCallback(async () => {
if (!file) return;
setIsTranscribing(true);

try {
const formData = new FormData();
formData.append("file", file);

const res = await fetch("/api/transcribe", {
method: "POST",
body: formData,
});

const data = await res.json();

if (!res.ok) {
throw new Error(data.error || "Subtitle generation failed.");
}

const segments = Array.isArray(data.segments) ? data.segments : [];
const fallbackDuration = duration && duration > 0 ? duration : 999999;
const subtitles = segments.length > 0
? segments.map((segment: any) => ({
id: crypto.randomUUID(),
text: String(segment.text || "").trim(),
startTime: Number.isFinite(segment.start) ? segment.start : 0,
endTime: Number.isFinite(segment.end) ? segment.end : fallbackDuration,
x: 50,
y: 90,
fontSize: 24,
color: "#ffffff",
}))
: data.text
? [
{
id: crypto.randomUUID(),
text: String(data.text).trim(),
startTime: 0,
endTime: fallbackDuration,
x: 50,
y: 90,
fontSize: 24,
color: "#ffffff",
},
]
: [];

if (subtitles.length === 0) {
throw new Error("No subtitles were generated.");
}

updateRecipe({ subtitles });
} catch (err: any) {
console.error("Subtitle generation error:", err);
alert(err?.message || "Unable to generate subtitles. Please try again.");
} finally {
setIsTranscribing(false);
}
}, [duration, file, updateRecipe]);

const initialOverlayState = useRef({
overlayPosition,
overlaySize,
Expand Down Expand Up @@ -499,6 +568,25 @@ return () => {
onSelectText={setSelectedTextId}
/>
</AccordionSection>
<Section
icon={<Type size={12} />}
title="Subtitles"
delay={115}
>
<button
type="button"
onClick={handleGenerateSubtitles}
disabled={isTranscribing}
className="w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm hover:bg-[var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{isTranscribing ? "Generating subtitles…" : "Generate Subtitles"}
</button>

<div className="mt-3 text-xs">
{recipe.subtitles.length} subtitle(s)
</div>
</Section>

</div>
<div className="bg-[var(--surface)] rounded-xl border border-[var(--border)] p-5 space-y-6">
<AccordionSection
Expand Down
43 changes: 42 additions & 1 deletion src/components/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"use client";

import { useEffect, useRef, useState, useCallback, RefObject } from "react";
import { EditRecipe, TextOverlay, TimelineTrack, MultiTrackEditorState } from "@/lib/types";
import { EditRecipe, Subtitle, TextOverlay, TimelineTrack, MultiTrackEditorState } from "@/lib/types";
import { getPresetById } from "@/lib/presets";
import { cn } from "@/lib/utils";
import { Camera } from "lucide-react";
Expand Down Expand Up @@ -37,6 +37,7 @@ export default function VideoPreview({
const [showOverlay, setShowOverlay] = useState(false);
const [showComparison, setShowComparison] = useState(false);
const [showGridOverlay, setShowGridOverlay] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [containerDimensions, setContainerDimensions] = useState({
width: 0,
height: 0,
Expand Down Expand Up @@ -167,6 +168,20 @@ export default function VideoPreview({
videoRef.current.playbackRate = recipe.speed;
}, [recipe, videoRef]);

useEffect(() => {
const video = videoRef.current;
if (!video) return;

const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};

video.addEventListener("timeupdate", handleTimeUpdate);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
};
}, [videoRef]);

/**
* Track preview container dimensions for text overlay positioning.
*/
Expand Down Expand Up @@ -282,6 +297,31 @@ export default function VideoPreview({
>
<track kind="captions" />
</video>
{/* Subtitles Layer */}
{recipe?.subtitles?.length ? (
<div className="absolute inset-0 pointer-events-none z-20">
{recipe.subtitles
.filter((sub) => currentTime >= sub.startTime && currentTime <= sub.endTime)
.map((sub) => (
<div
key={sub.id}
style={{
position: "absolute",
left: `${sub.x}%`,
top: `${sub.y}%`,
transform: "translate(-50%, -50%)",
color: "#ffffff",
fontSize: `${sub.fontSize}px`,
fontWeight: 600,
textShadow: "0 2px 6px rgba(0,0,0,0.8)",
whiteSpace: "nowrap",
}}
>
{sub.text}
</div>
))}
</div>
) : null}

{/* Phase 1 MVP: Multi-track overlay rendering */}
{multiTrackState && multiTrackVideoRefs && multiTrackState.timelineTracks.length > 1 && (
Expand Down Expand Up @@ -315,6 +355,7 @@ export default function VideoPreview({
>
<track kind="captions" />
</video>

);
})}
</div>
Expand Down
Loading