what am i doing

This commit is contained in:
Maze Winther 2026-01-21 14:54:12 +01:00
parent afdf7d22cf
commit f3c91d5cb4
29 changed files with 1881 additions and 2326 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
{"encryption.key":"6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o=","encryption.expire_at":1769323998372}

View File

@ -1 +0,0 @@
{"type": "commonjs"}

View File

@ -1,3 +0,0 @@
{
"/editor/[project_id]/page": "app/editor/[project_id]/page.js"
}

View File

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o=\"\n}"

View File

@ -1,5 +0,0 @@
{
"node": {},
"edge": {},
"encryptionKey": "6csmILMKXW5vUdcKm142jEES+lg717F52EbuARHN44o="
}

File diff suppressed because one or more lines are too long

View File

@ -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]>
}
}

View File

@ -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",

View File

@ -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" />

View File

@ -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");

View File

@ -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,

View File

@ -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={() => {

View File

@ -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";

View File

@ -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";

View File

@ -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 });

View File

@ -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,

View File

@ -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";

View File

@ -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()}
/>
);
}

View File

@ -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;

View File

@ -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();

View File

@ -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({

View File

@ -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

View File

@ -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=="],