what am i doing
This commit is contained in:
parent
afdf7d22cf
commit
f3c91d5cb4
File diff suppressed because it is too large
Load Diff
|
|
@ -1 +0,0 @@
|
|||
{"encryption.key":"6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o=","encryption.expire_at":1769323998372}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"type": "commonjs"}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"/editor/[project_id]/page": "app/editor/[project_id]/page.js"
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
|
|
@ -1 +0,0 @@
|
|||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o=\"\n}"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o="
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,90 +0,0 @@
|
|||
// This file is generated automatically by Next.js
|
||||
// Do not edit this file manually
|
||||
|
||||
type AppRoutes = "/" | "/blog" | "/blog/[slug]" | "/contributors" | "/editor/[project_id]" | "/privacy" | "/projects" | "/roadmap" | "/sponsors" | "/terms"
|
||||
type AppRouteHandlerRoutes = "/api/auth/[...all]" | "/api/get-upload-url" | "/api/health" | "/api/sounds/search" | "/api/transcribe" | "/rss.xml"
|
||||
type PageRoutes = never
|
||||
type LayoutRoutes = "/" | "/editor/[project_id]"
|
||||
type RedirectRoutes = never
|
||||
type RewriteRoutes = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...path]]" | "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js"
|
||||
type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes | AppRouteHandlerRoutes
|
||||
|
||||
|
||||
interface ParamMap {
|
||||
"/": {}
|
||||
"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/[[...path]]": { "path"?: string[]; }
|
||||
"/149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/a-4-a/c.js": {}
|
||||
"/api/auth/[...all]": { "all": string[]; }
|
||||
"/api/get-upload-url": {}
|
||||
"/api/health": {}
|
||||
"/api/sounds/search": {}
|
||||
"/api/transcribe": {}
|
||||
"/blog": {}
|
||||
"/blog/[slug]": { "slug": string; }
|
||||
"/contributors": {}
|
||||
"/editor/[project_id]": { "project_id": string; }
|
||||
"/privacy": {}
|
||||
"/projects": {}
|
||||
"/roadmap": {}
|
||||
"/rss.xml": {}
|
||||
"/sponsors": {}
|
||||
"/terms": {}
|
||||
}
|
||||
|
||||
|
||||
export type ParamsOf<Route extends Routes> = ParamMap[Route]
|
||||
|
||||
interface LayoutSlotMap {
|
||||
"/": never
|
||||
"/editor/[project_id]": never
|
||||
}
|
||||
|
||||
|
||||
export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap, AppRouteHandlerRoutes }
|
||||
|
||||
declare global {
|
||||
/**
|
||||
* Props for Next.js App Router page components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Page(props: PageProps<'/blog/[slug]'>) {
|
||||
* const { slug } = await props.params
|
||||
* return <div>Blog post: {slug}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface PageProps<AppRoute extends AppRoutes> {
|
||||
params: Promise<ParamMap[AppRoute]>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for Next.js App Router layout components
|
||||
* @example
|
||||
* ```tsx
|
||||
* export default function Layout(props: LayoutProps<'/dashboard'>) {
|
||||
* return <div>{props.children}</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
|
||||
params: Promise<ParamMap[LayoutRoute]>
|
||||
children: React.ReactNode
|
||||
} & {
|
||||
[K in LayoutSlotMap[LayoutRoute]]: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for Next.js App Router route handlers
|
||||
* @example
|
||||
* ```tsx
|
||||
* export async function GET(request: NextRequest, context: RouteContext<'/api/users/[id]'>) {
|
||||
* const { id } = await context.params
|
||||
* return Response.json({ id })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface RouteContext<AppRouteHandlerRoute extends AppRouteHandlerRoutes> {
|
||||
params: Promise<ParamMap[AppRouteHandlerRoute]>
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
"feed": "^5.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mediabunny": "^1.9.3",
|
||||
"mediabunny": "^1.29.1",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import Link from "next/link";
|
|||
import { useRouter } from "next/navigation";
|
||||
import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
||||
import { DeleteProjectDialog } from "@/components/editor/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
|
@ -419,14 +419,12 @@ function ProjectCard({
|
|||
|
||||
const cardContent = (
|
||||
<Card
|
||||
className={`bg-background overflow-hidden border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-primary ring-2" : ""
|
||||
}`}
|
||||
className={`bg-background overflow-hidden border-none p-0 transition-all ${isSelectionMode && isSelected ? "ring-primary ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`bg-muted relative aspect-square transition-opacity ${
|
||||
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
className={`bg-muted relative aspect-square transition-opacity ${isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
|
|
@ -477,11 +475,10 @@ function ProjectCard({
|
|||
aria-label="project options"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`ml-2 size-6 shrink-0 p-0 transition-all ${
|
||||
isDropdownOpen
|
||||
className={`ml-2 size-6 shrink-0 p-0 transition-all ${isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
}`}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { Loader2 } from "lucide-react";
|
|||
import type { TranscriptionProgress } from "@/types/transcription";
|
||||
import { transcriptionService } from "@/services/transcription";
|
||||
import { decodeAudioToFloat32 } from "@/lib/media/audio";
|
||||
import { buildCaptionChunks } from "@/lib/utils/caption-utils";
|
||||
import { buildCaptionChunks } from "@/lib/transcription/caption";
|
||||
|
||||
export function Captions() {
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("auto");
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import { DraggableItem } from "@/components/editor/draggable-item";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
} from "@/lib/iconify-api";
|
||||
import { cn } from "@/utils/ui";
|
||||
import Image from "next/image";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import { DraggableItem } from "@/components/editor/draggable-item";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
|
||||
|
|
@ -208,17 +208,17 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
|||
const collection = collections[c.prefix];
|
||||
return collection
|
||||
? {
|
||||
prefix: c.prefix,
|
||||
name: c.name,
|
||||
total: collection.total,
|
||||
}
|
||||
prefix: c.prefix,
|
||||
name: c.name,
|
||||
total: collection.total,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
}>;
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
}>;
|
||||
}, [collections, category]);
|
||||
|
||||
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
|
||||
|
|
@ -531,10 +531,10 @@ function StickerItem({
|
|||
hostIndex === 0
|
||||
? getIconSvgUrl(iconName, { width: 64, height: 64 })
|
||||
: buildIconSvgUrl(
|
||||
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
|
||||
iconName,
|
||||
{ width: 64, height: 64 },
|
||||
)
|
||||
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
|
||||
iconName,
|
||||
{ width: 64, height: 64 },
|
||||
)
|
||||
}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
|
|
@ -543,9 +543,9 @@ function StickerItem({
|
|||
style={
|
||||
capSize
|
||||
? {
|
||||
maxWidth: "var(--sticker-max, 160px)",
|
||||
maxHeight: "var(--sticker-max, 160px)",
|
||||
}
|
||||
maxWidth: "var(--sticker-max, 160px)",
|
||||
maxHeight: "var(--sticker-max, 160px)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onError={() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import { DraggableItem } from "@/components/editor/draggable-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from "../ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { RenameProjectDialog } from "../rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "../delete-project-dialog";
|
||||
import { DeleteProjectDialog } from "./delete-project-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { ExportButton } from "./export-button";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
|||
import { Progress } from "../ui/progress";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getExportMimeType, getExportFileExtension } from "@/utils/export";
|
||||
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import {
|
||||
EXPORT_FORMAT_VALUES,
|
||||
|
|
@ -108,7 +108,6 @@ function ExportPopover({
|
|||
setExportResult(result);
|
||||
|
||||
if (result.success && result.buffer) {
|
||||
// Download the file
|
||||
const mimeType = getExportMimeType({ format });
|
||||
const extension = getExportFileExtension({ format });
|
||||
const blob = new Blob([result.buffer], { type: mimeType });
|
||||
|
|
|
|||
|
|
@ -36,13 +36,12 @@ import {
|
|||
getTotalTracksHeight,
|
||||
canTracktHaveAudio,
|
||||
canTrackBeHidden,
|
||||
isMainTrack,
|
||||
getTimelineZoomMin,
|
||||
} from "@/lib/timeline";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { useTimelineInteractions } from "@/hooks/timeline/use-timeline-interactions";
|
||||
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
import { TimelineBookmarksRow } from "./bookmarks";
|
||||
|
|
@ -162,7 +161,7 @@ export function Timeline() {
|
|||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
} = useTimelineInteractions({
|
||||
} = useTimelineSeek({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
rulerScrollRef,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
import { Slider } from "@/components/ui/slider";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||
import { EditableTimecode } from "@/components/editor/editable-timecode";
|
||||
import { ScenesView } from "../scenes-view";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
className?: string;
|
||||
clipStartTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
clipDuration: number;
|
||||
trackMuted?: boolean;
|
||||
}
|
||||
|
||||
export function AudioPlayer({
|
||||
src,
|
||||
className = "",
|
||||
clipStartTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
clipDuration,
|
||||
trackMuted = false,
|
||||
}: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const volume = editor.playback.getVolume();
|
||||
const muted = editor.playback.isMuted();
|
||||
const speed = editor.playback.getSpeed();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
const isInClipRange =
|
||||
currentTime >= clipStartTime && currentTime < clipEndTime;
|
||||
|
||||
// Sync playback events
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !isInClipRange) return;
|
||||
|
||||
const handleSeekEvent = (e: CustomEvent) => {
|
||||
// Always update audio time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const audioTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart,
|
||||
),
|
||||
);
|
||||
audio.currentTime = audioTime;
|
||||
};
|
||||
|
||||
const handleUpdateEvent = (e: CustomEvent) => {
|
||||
// Always update audio time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const targetTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart,
|
||||
),
|
||||
);
|
||||
|
||||
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
|
||||
audio.currentTime = targetTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeed = (e: CustomEvent) => {
|
||||
audio.playbackRate = e.detail.speed;
|
||||
};
|
||||
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener,
|
||||
);
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"playback-seek",
|
||||
handleSeekEvent as EventListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-speed",
|
||||
handleSpeed as EventListener,
|
||||
);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
// Sync playback state
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying && isInClipRange && !trackMuted) {
|
||||
audio.play().catch(() => {});
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, [isPlaying, isInClipRange, trackMuted]);
|
||||
|
||||
// Sync volume and speed
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
audio.volume = volume;
|
||||
audio.muted = muted || trackMuted;
|
||||
audio.playbackRate = speed;
|
||||
}, [volume, speed, muted, trackMuted]);
|
||||
|
||||
return (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
className={className}
|
||||
preload="auto"
|
||||
controls={false}
|
||||
style={{ display: "none" }} // Audio elements don't need visual representation
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,3 +5,8 @@ export const DEFAULT_EXPORT_OPTIONS = {
|
|||
quality: "high",
|
||||
includeAudio: true,
|
||||
} satisfies ExportOptions;
|
||||
|
||||
export const EXPORT_MIME_TYPES = {
|
||||
webm: "video/webm",
|
||||
mp4: "video/mp4",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
|||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseTimelineInteractionsProps {
|
||||
interface UseTimelineSeekProps {
|
||||
playheadRef: RefObject<HTMLDivElement>;
|
||||
trackLabelsRef: RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement>;
|
||||
|
|
@ -54,7 +54,7 @@ function setMouseTracking({
|
|||
};
|
||||
}
|
||||
|
||||
export function useTimelineInteractions({
|
||||
export function useTimelineSeek({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
rulerScrollRef,
|
||||
|
|
@ -64,7 +64,7 @@ export function useTimelineInteractions({
|
|||
isSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineInteractionsProps) {
|
||||
}: UseTimelineSeekProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
|
||||
import { ExportFormat } from "@/types/export";
|
||||
|
||||
export function getExportMimeType({
|
||||
|
|
@ -5,12 +6,7 @@ export function getExportMimeType({
|
|||
}: {
|
||||
format: ExportFormat;
|
||||
}): string {
|
||||
const mimeTypes: Record<ExportFormat, string> = {
|
||||
webm: "video/webm",
|
||||
mp4: "video/mp4",
|
||||
};
|
||||
|
||||
return mimeTypes[format];
|
||||
return EXPORT_MIME_TYPES[format];
|
||||
}
|
||||
|
||||
export function getExportFileExtension({
|
||||
|
|
@ -1,20 +1,8 @@
|
|||
import { FFmpeg } from "@ffmpeg/ffmpeg";
|
||||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
|
||||
import { collectAudioMixSources } from "@/lib/media/audio";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
|
||||
let ffmpeg: FFmpeg | null = null;
|
||||
|
||||
export const initFFmpeg = async (): Promise<FFmpeg> => {
|
||||
if (ffmpeg) return ffmpeg;
|
||||
|
||||
ffmpeg = new FFmpeg();
|
||||
await ffmpeg.load(); // Use default config
|
||||
|
||||
return ffmpeg;
|
||||
};
|
||||
|
||||
export async function getVideoInfo({
|
||||
videoFile,
|
||||
}: {
|
||||
|
|
@ -37,7 +25,6 @@ export async function getVideoInfo({
|
|||
throw new Error("No video track found in the file");
|
||||
}
|
||||
|
||||
// Get frame rate from packet statistics
|
||||
const packetStats = await videoTrack.computePacketStats(100);
|
||||
const fps = packetStats.averagePacketRate;
|
||||
|
||||
|
|
@ -49,8 +36,9 @@ export async function getVideoInfo({
|
|||
};
|
||||
}
|
||||
|
||||
// audio mixing for timeline - keeping ffmpeg for now due to complexity
|
||||
// TODO: Replace with Mediabunny audio processing when implementing canvas preview
|
||||
const SAMPLE_RATE = 44100;
|
||||
const NUM_CHANNELS = 2;
|
||||
|
||||
export const extractTimelineAudio = async ({
|
||||
tracks,
|
||||
mediaAssets,
|
||||
|
|
@ -62,24 +50,8 @@ export const extractTimelineAudio = async ({
|
|||
totalDuration: number;
|
||||
onProgress?: (progress: number) => void;
|
||||
}): Promise<Blob> => {
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
try {
|
||||
await ffmpeg.load();
|
||||
} catch (error) {
|
||||
console.error("Failed to load fresh FFmpeg instance:", error);
|
||||
throw new Error("Unable to initialize audio processing. Please try again.");
|
||||
}
|
||||
|
||||
if (totalDuration === 0) {
|
||||
const emptyAudioData = new ArrayBuffer(44);
|
||||
return new Blob([emptyAudioData], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
ffmpeg.on("progress", ({ progress }) => {
|
||||
onProgress(progress * 100);
|
||||
});
|
||||
return createWavBlob({ samples: new Float32Array(SAMPLE_RATE * 0.1) });
|
||||
}
|
||||
|
||||
const audioMixSources = await collectAudioMixSources({
|
||||
|
|
@ -88,140 +60,159 @@ export const extractTimelineAudio = async ({
|
|||
});
|
||||
|
||||
if (audioMixSources.length === 0) {
|
||||
// Return silent audio if no audio elements
|
||||
const silentDuration = Math.max(1, totalDuration); // At least 1 second
|
||||
const silentDuration = Math.max(1, totalDuration);
|
||||
const silentSamples = new Float32Array(
|
||||
Math.ceil(silentDuration * SAMPLE_RATE) * NUM_CHANNELS,
|
||||
);
|
||||
return createWavBlob({ samples: silentSamples });
|
||||
}
|
||||
|
||||
const totalSamples = Math.ceil(totalDuration * SAMPLE_RATE);
|
||||
const mixBuffers = [
|
||||
new Float32Array(totalSamples),
|
||||
new Float32Array(totalSamples),
|
||||
];
|
||||
|
||||
for (let i = 0; i < audioMixSources.length; i++) {
|
||||
const source = audioMixSources[i];
|
||||
|
||||
if (onProgress) {
|
||||
onProgress((i / audioMixSources.length) * 90);
|
||||
}
|
||||
|
||||
try {
|
||||
const silentAudio = await generateSilentAudio(silentDuration);
|
||||
return silentAudio;
|
||||
await decodeAndMixAudioSource({
|
||||
source,
|
||||
mixBuffers,
|
||||
totalSamples,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to generate silent audio:", error);
|
||||
throw new Error("Unable to generate audio for empty timeline.");
|
||||
console.warn(`Failed to process audio source ${source.file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a complex filter to mix all audio sources
|
||||
const inputFiles: string[] = [];
|
||||
const filterInputs: string[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < audioMixSources.length; i++) {
|
||||
const element = audioMixSources[i];
|
||||
const inputName = `input_${i}.${element.file.name.split(".").pop()}`;
|
||||
inputFiles.push(inputName);
|
||||
|
||||
try {
|
||||
await ffmpeg.writeFile(
|
||||
inputName,
|
||||
new Uint8Array(await element.file.arrayBuffer()),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to write file ${element.file.name}:`, error);
|
||||
throw new Error(
|
||||
`Unable to process file: ${element.file.name}. The file may be corrupted or in an unsupported format.`,
|
||||
);
|
||||
}
|
||||
|
||||
const actualStart = element.trimStart;
|
||||
const actualDuration = element.duration;
|
||||
|
||||
const filterName = `audio_${i}`;
|
||||
filterInputs.push(
|
||||
`[${i}:a]atrim=start=${actualStart}:duration=${actualDuration},asetpts=PTS-STARTPTS,adelay=${element.startTime * 1000}|${element.startTime * 1000}[${filterName}]`,
|
||||
);
|
||||
// clamp to prevent clipping
|
||||
for (const channel of mixBuffers) {
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
channel[i] = Math.max(-1, Math.min(1, channel[i]));
|
||||
}
|
||||
|
||||
const mixFilter =
|
||||
audioMixSources.length === 1
|
||||
? `[audio_0]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`
|
||||
: `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioMixSources.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`;
|
||||
|
||||
const complexFilter = [...filterInputs, mixFilter].join(";");
|
||||
const outputName = "timeline_audio.wav";
|
||||
|
||||
const ffmpegArgs = [
|
||||
...inputFiles.flatMap((name) => ["-i", name]),
|
||||
"-filter_complex",
|
||||
complexFilter,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-t",
|
||||
totalDuration.toString(),
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"44100",
|
||||
outputName,
|
||||
];
|
||||
|
||||
try {
|
||||
await ffmpeg.exec(ffmpegArgs);
|
||||
} catch (error) {
|
||||
console.error("FFmpeg execution failed:", error);
|
||||
throw new Error(
|
||||
"Audio processing failed. Some audio files may be corrupted or incompatible.",
|
||||
);
|
||||
}
|
||||
|
||||
const data = await ffmpeg.readFile(outputName);
|
||||
const blob = new Blob([data], { type: "audio/wav" });
|
||||
|
||||
return blob;
|
||||
} catch (error) {
|
||||
for (const inputFile of inputFiles) {
|
||||
try {
|
||||
await ffmpeg.deleteFile(inputFile);
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Failed to cleanup file ${inputFile}:`, cleanupError);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await ffmpeg.deleteFile("timeline_audio.wav");
|
||||
} catch (cleanupError) {
|
||||
console.warn("Failed to cleanup output file:", cleanupError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
for (const inputFile of inputFiles) {
|
||||
try {
|
||||
await ffmpeg.deleteFile(inputFile);
|
||||
} catch (cleanupError) {}
|
||||
}
|
||||
try {
|
||||
await ffmpeg.deleteFile("timeline_audio.wav");
|
||||
} catch (cleanupError) {}
|
||||
}
|
||||
|
||||
// interleave channels for wav output
|
||||
const interleavedSamples = new Float32Array(totalSamples * NUM_CHANNELS);
|
||||
for (let i = 0; i < totalSamples; i++) {
|
||||
interleavedSamples[i * 2] = mixBuffers[0][i];
|
||||
interleavedSamples[i * 2 + 1] = mixBuffers[1][i];
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(100);
|
||||
}
|
||||
|
||||
return createWavBlob({ samples: interleavedSamples });
|
||||
};
|
||||
|
||||
const generateSilentAudio = async (durationSeconds: number): Promise<Blob> => {
|
||||
const ffmpeg = await initFFmpeg();
|
||||
const outputName = "silent.wav";
|
||||
async function decodeAndMixAudioSource({
|
||||
source,
|
||||
mixBuffers,
|
||||
totalSamples,
|
||||
}: {
|
||||
source: { file: File; startTime: number; duration: number; trimStart: number };
|
||||
mixBuffers: Float32Array[];
|
||||
totalSamples: number;
|
||||
}): Promise<void> {
|
||||
const input = new Input({
|
||||
source: new BlobSource(source.file),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
try {
|
||||
await ffmpeg.exec([
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`anullsrc=channel_layout=stereo:sample_rate=44100`,
|
||||
"-t",
|
||||
durationSeconds.toString(),
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
outputName,
|
||||
]);
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
if (!audioTrack) return;
|
||||
|
||||
const data = await ffmpeg.readFile(outputName);
|
||||
const blob = new Blob([data], { type: "audio/wav" });
|
||||
const sink = new AudioBufferSink(audioTrack);
|
||||
const trimEnd = source.trimStart + source.duration;
|
||||
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error("Failed to generate silent audio:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await ffmpeg.deleteFile(outputName);
|
||||
} catch (cleanupError) {
|
||||
// Silent cleanup
|
||||
for await (const { buffer, timestamp } of sink.buffers(
|
||||
source.trimStart,
|
||||
trimEnd,
|
||||
)) {
|
||||
const relativeTime = timestamp - source.trimStart;
|
||||
const outputStartSample = Math.floor(
|
||||
(source.startTime + relativeTime) * SAMPLE_RATE,
|
||||
);
|
||||
|
||||
// resample if needed
|
||||
const resampleRatio = SAMPLE_RATE / buffer.sampleRate;
|
||||
|
||||
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
||||
const sourceChannel = Math.min(ch, buffer.numberOfChannels - 1);
|
||||
const channelData = buffer.getChannelData(sourceChannel);
|
||||
const outputChannel = mixBuffers[ch];
|
||||
|
||||
const resampledLength = Math.floor(channelData.length * resampleRatio);
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
const outputIdx = outputStartSample + i;
|
||||
if (outputIdx < 0 || outputIdx >= totalSamples) continue;
|
||||
|
||||
const sourceIdx = Math.floor(i / resampleRatio);
|
||||
if (sourceIdx < channelData.length) {
|
||||
outputChannel[outputIdx] += channelData[sourceIdx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createWavBlob({ samples }: { samples: Float32Array }): Blob {
|
||||
const numChannels = NUM_CHANNELS;
|
||||
const bitsPerSample = 16;
|
||||
const bytesPerSample = bitsPerSample / 8;
|
||||
const numSamples = samples.length / numChannels;
|
||||
const dataSize = numSamples * numChannels * bytesPerSample;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// riff header
|
||||
writeString({ view, offset: 0, str: "RIFF" });
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString({ view, offset: 8, str: "WAVE" });
|
||||
|
||||
// fmt chunk
|
||||
writeString({ view, offset: 12, str: "fmt " });
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, SAMPLE_RATE, true);
|
||||
view.setUint32(28, SAMPLE_RATE * numChannels * bytesPerSample, true);
|
||||
view.setUint16(32, numChannels * bytesPerSample, true);
|
||||
view.setUint16(34, bitsPerSample, true);
|
||||
|
||||
// data chunk
|
||||
writeString({ view, offset: 36, str: "data" });
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// convert float32 to int16 and write
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[i]));
|
||||
const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
view.setInt16(offset, int16, true);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
function writeString({
|
||||
view,
|
||||
offset,
|
||||
str,
|
||||
}: {
|
||||
view: DataView;
|
||||
offset: number;
|
||||
str: string;
|
||||
}): void {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
4
bun.lock
4
bun.lock
|
|
@ -44,7 +44,7 @@
|
|||
"feed": "^5.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mediabunny": "^1.9.3",
|
||||
"mediabunny": "^1.29.1",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
|
|
@ -834,7 +834,7 @@
|
|||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"mediabunny": ["mediabunny@1.9.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-uVspdzrpwvW8NxtGspHPOJvdNSY0dNFEmOQngSheX9PYX9oXijZENBjfYWOgm9c9IgSSlN2bdJF9iyPyY3f+fQ=="],
|
||||
"mediabunny": ["mediabunny@1.29.1", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-RrlKs69MxRGa/l9cMGeI4hzuTSgchOGFHk9lIAuu9EcbSdJ05gDbRsTY67dojAiyUP3ic4PExij5OqDxqSv82Q=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue