diff --git a/apps/web/package.json b/apps/web/package.json index a794741dd..06f1499dd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,10 +39,12 @@ "dotenv": "^16.5.0", "drizzle-orm": "^0.44.2", "embla-carousel-react": "^8.5.1", + "eventemitter3": "^5.0.1", "feed": "^5.1.0", "framer-motion": "^11.13.1", "input-otp": "^1.4.1", "lucide-react": "^0.468.0", + "mediabunny": "^1.11.2", "motion": "^12.18.1", "nanoid": "^5.1.5", "next": "^15.4.5", @@ -67,6 +69,7 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", + "use-deep-compare-effect": "^1.8.1", "vaul": "^1.1.1", "zod": "^3.25.67", "zustand": "^5.0.2" diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 4d5a27fcd..8573b5ecc 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -10,13 +10,14 @@ import { import { MediaPanel } from "../../../components/editor/media-panel"; import { PropertiesPanel } from "../../../components/editor/properties-panel"; import { Timeline } from "../../../components/editor/timeline"; -import { PreviewPanel } from "../../../components/editor/preview-panel"; import { EditorHeader } from "@/components/editor-header"; import { usePanelStore } from "@/stores/panel-store"; import { useProjectStore } from "@/stores/project-store"; import { EditorProvider } from "@/components/editor-provider"; import { usePlaybackControls } from "@/hooks/use-playback-controls"; import { Onboarding } from "@/components/onboarding"; +import { CanvasPreviewPanel as PreviewPanel } from "@/components/editor/renderer/canvas-preview-panel"; +// import { PreviewPanel } from "@/components/editor/preview-panel"; export default function Editor() { const { @@ -275,6 +276,7 @@ export default function Editor() { onResize={setPreviewPanel} className="min-w-0 min-h-0 flex-1" > + {/* */} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index ee5dfc178..006a1572d 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -20,7 +20,8 @@ import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { useTheme } from "next-themes"; import { PanelPresetSelector } from "./panel-preset-selector"; -import { ExportButton } from "./export-button"; +// import { ExportButton } from "./export-button"; +import { ExportDialog } from "./editor/renderer/export-dialog"; export function EditorHeader() { const { activeProject, renameProject, deleteProject } = useProjectStore(); @@ -116,7 +117,9 @@ export function EditorHeader() { - + + Export + s.setScene); + + const tracks = useTimelineStore((s) => s.tracks); + const mediaItems = useMediaStore((s) => s.mediaItems); + + const getTotalDuration = useTimelineStore((s) => s.getTotalDuration); + const { width, height } = usePreviewSize(); + + useDeepCompareEffect(() => { + const scene = buildScene({ + tracks, + mediaItems, + duration: getTotalDuration(), + canvasSize: { + width, + height, + }, + }); + + setScene(scene); + }, [tracks, mediaItems, getTotalDuration]); + + return null; +} + +function PreviewCanvas() { + const ref = useRef(null); + const lastFrameRef = useRef(0); + const lastSceneRef = useRef(null); + const renderingRef = useRef(false); + + const { width, height } = usePreviewSize(); + + const renderer = useMemo(() => { + return new SceneRenderer({ + width, + height, + fps: 30, // TODO: get fps from project + }); + }, [width, height]); + + const scene = useRendererStore((s) => s.scene); + + const render = useCallback(() => { + if (ref.current && scene && !renderingRef.current) { + const time = usePlaybackStore.getState().currentTime; + const frame = Math.floor(time * renderer.fps); + + if (frame !== lastFrameRef.current || scene !== lastSceneRef.current) { + renderingRef.current = true; + lastSceneRef.current = scene; + lastFrameRef.current = frame; + renderer.renderToCanvas(scene, frame, ref.current).then(() => { + renderingRef.current = false; + }); + } + } + }, [renderer, scene, width, height]); + + useRafLoop(render); + + return ( + + ); +} + +export function CanvasPreviewPanel() { + return ( + + + + + + + ); +} diff --git a/apps/web/src/components/editor/renderer/export-dialog.tsx b/apps/web/src/components/editor/renderer/export-dialog.tsx new file mode 100644 index 000000000..4e6de4b50 --- /dev/null +++ b/apps/web/src/components/editor/renderer/export-dialog.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { SceneExporter } from "@/lib/renderer/scene-exporter"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { buildScene } from "@/lib/renderer/build-scene"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { useProjectStore } from "@/stores/project-store"; + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); +} + +function ExportProgress({ progress }: { progress: number }) { + return ( + + + Rendering video... + + {Math.round(progress * 100)}% + + + + + ); +} + +export function ExportDialog({ children }: { children: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + const [exporter, setExporter] = useState(null); + + const handleOpenChange = (open: boolean) => { + if (isExporting) { + return; + } + + setIsOpen(open); + setError(null); + setProgress(0); + }; + + const handleExport = async () => { + setProgress(0); + setIsExporting(true); + + const project = useProjectStore.getState().activeProject; + + const width = project?.canvasSize.width ?? 640; + const height = project?.canvasSize.height ?? 720; + const fps = project?.fps ?? 30; + + const scene = buildScene({ + tracks: useTimelineStore.getState().tracks, + mediaItems: useMediaStore.getState().mediaItems, + duration: useTimelineStore.getState().getTotalDuration(), + canvasSize: { + width, + height, + }, + }); + + const exporter = new SceneExporter({ + width, + height, + fps, + }); + + setExporter(exporter); + + exporter.on("progress", (progress) => { + setProgress(progress); + }); + + exporter.on("complete", (blob) => { + downloadBlob(blob, `export-${Date.now()}.mp4`); + setIsOpen(false); + }); + + exporter.on("error", (error) => { + setError(error.message); + }); + + await exporter.export(scene); + setIsExporting(false); + setExporter(null); + }; + + const handleCancel = () => { + exporter?.cancel(); + setIsExporting(false); + setIsOpen(false); + }; + + return ( + + {children} + + + Export Video + + Export the scene as a video file. + + {isExporting && } + {error && {error}} + + + {isExporting && ( + + Cancel + + )} + + Export + + + + + ); +} diff --git a/apps/web/src/hooks/use-raf-loop.ts b/apps/web/src/hooks/use-raf-loop.ts new file mode 100644 index 000000000..babb66dd7 --- /dev/null +++ b/apps/web/src/hooks/use-raf-loop.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef } from "react"; + +export function useRafLoop(callback: (time: number) => void) { + const requestRef = useRef(0); + const previousTimeRef = useRef(0); + + useEffect(() => { + const loop = (time: number) => { + if (previousTimeRef.current !== undefined) { + const deltaTime = time - previousTimeRef.current; + callback(deltaTime); + } + previousTimeRef.current = time; + requestRef.current = requestAnimationFrame(loop); + }; + + requestRef.current = requestAnimationFrame(loop); + return () => cancelAnimationFrame(requestRef.current); + }, [callback]); +} diff --git a/apps/web/src/lib/renderer/build-scene.ts b/apps/web/src/lib/renderer/build-scene.ts new file mode 100644 index 000000000..f74921994 --- /dev/null +++ b/apps/web/src/lib/renderer/build-scene.ts @@ -0,0 +1,69 @@ +import { type TimelineTrack } from "@/types/timeline"; +import { type MediaItem } from "@/stores/media-store"; + +import { SceneNode } from "./nodes/scene-node"; +import { VideoNode } from "./nodes/video-node"; +import { TimecodeNode } from "./nodes/timecode-node"; +import { TextNode } from "./nodes/text-node"; + +export type BuildSceneParams = { + canvasSize: { width: number; height: number }; + tracks: TimelineTrack[]; + mediaItems: MediaItem[]; + duration: number; +}; + +export function buildScene(params: BuildSceneParams) { + const { tracks, mediaItems, duration, canvasSize } = params; + + const scene = new SceneNode({ duration }); + + const elements = tracks + .slice() + .reverse() + .filter((track) => track.muted !== true) + .flatMap((track) => track.elements); + + for (const element of elements) { + if (element.type === "media") { + const media = mediaItems.find((m) => m.id === element.mediaId); + console.log(element); + if (media && media.url) { + scene.add( + new VideoNode({ + video: media.url, + duration: element.duration, + timeOffset: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + }) + ); + } + } + + if (element.type === "text") { + console.log(element); + scene.add( + new TextNode({ + text: element.content, + fontSize: element.fontSize, + fontFamily: element.fontFamily, + fontWeight: element.fontWeight === "bold" ? 700 : 400, + fontStyle: element.fontStyle === "italic" ? "italic" : "normal", + textAlign: element.textAlign, + textBaseline: "middle", + color: element.color, + opacity: element.opacity, + timeStart: element.startTime, + duration: element.duration - element.trimEnd - element.trimStart, + x: element.x + canvasSize.width / 2, + y: element.y + canvasSize.height / 2, + }) + ); + } + } + + scene.add(new TimecodeNode()); + + return scene; +} diff --git a/apps/web/src/lib/renderer/nodes/base-node.ts b/apps/web/src/lib/renderer/nodes/base-node.ts new file mode 100644 index 000000000..e2f2275ee --- /dev/null +++ b/apps/web/src/lib/renderer/nodes/base-node.ts @@ -0,0 +1,29 @@ +import { SceneRenderer } from "../scene-renderer"; + +export type BaseNodeParams = Record | undefined; + +export class BaseNode { + params: Params; + + constructor(params?: Params) { + this.params = params ?? ({} as Params); + } + + children: BaseNode[] = []; + + add(child: BaseNode) { + this.children.push(child); + return this; + } + + remove(child: BaseNode) { + this.children = this.children.filter((c) => c !== child); + return this; + } + + async render(renderer: SceneRenderer, time: number): Promise { + for (const child of this.children) { + await child.render(renderer, time); + } + } +} diff --git a/apps/web/src/lib/renderer/nodes/color-node.ts b/apps/web/src/lib/renderer/nodes/color-node.ts new file mode 100644 index 000000000..44e0d631e --- /dev/null +++ b/apps/web/src/lib/renderer/nodes/color-node.ts @@ -0,0 +1,20 @@ +import { SceneRenderer } from "../scene-renderer"; +import { BaseNode } from "./base-node"; + +export type ColorNodeParams = { + color: string; +}; + +export class ColorNode extends BaseNode { + private color: string; + + constructor(params: ColorNodeParams) { + super(params); + this.color = params.color; + } + + async render(renderer: SceneRenderer, time: number) { + renderer.context.fillStyle = this.color; + renderer.context.fillRect(0, 0, renderer.width, renderer.height); + } +} diff --git a/apps/web/src/lib/renderer/nodes/scene-node.ts b/apps/web/src/lib/renderer/nodes/scene-node.ts new file mode 100644 index 000000000..f60e4c4e2 --- /dev/null +++ b/apps/web/src/lib/renderer/nodes/scene-node.ts @@ -0,0 +1,11 @@ +import { BaseNode } from "./base-node"; + +export type SceneNodeParams = { + duration: number; +}; + +export class SceneNode extends BaseNode { + get duration() { + return this.params.duration ?? 0; + } +} diff --git a/apps/web/src/lib/renderer/nodes/text-node.ts b/apps/web/src/lib/renderer/nodes/text-node.ts new file mode 100644 index 000000000..061f3c3dd --- /dev/null +++ b/apps/web/src/lib/renderer/nodes/text-node.ts @@ -0,0 +1,50 @@ +import { SceneRenderer } from "../scene-renderer"; +import { BaseNode } from "./base-node"; + +export type TextNodeParams = { + text: string; + fontSize: number; + fontFamily: string; + fontWeight: number; + fontStyle: string; + textAlign: CanvasTextAlign; + textBaseline: CanvasTextBaseline; + color: string; + opacity: number; + + x: number; + y: number; + + timeStart: number; + duration: number; +}; + +export class TextNode extends BaseNode { + isInRange(time: number) { + return ( + time >= this.params.timeStart && + time < this.params.timeStart + this.params.duration + ); + } + + async render(renderer: SceneRenderer, time: number) { + if (!this.isInRange(time)) { + return; + } + + renderer.context.save(); + + renderer.context.font = `${this.params.fontStyle} ${this.params.fontWeight} ${this.params.fontSize}px ${this.params.fontFamily}`; + renderer.context.textAlign = this.params.textAlign; + renderer.context.textBaseline = this.params.textBaseline; + renderer.context.fillStyle = this.params.color; + + const prevAlpha = renderer.context.globalAlpha; + renderer.context.globalAlpha = this.params.opacity; + + renderer.context.fillText(this.params.text, this.params.x, this.params.y); + + renderer.context.globalAlpha = prevAlpha; + renderer.context.restore(); + } +} diff --git a/apps/web/src/lib/renderer/nodes/timecode-node.ts b/apps/web/src/lib/renderer/nodes/timecode-node.ts new file mode 100644 index 000000000..5307857c8 --- /dev/null +++ b/apps/web/src/lib/renderer/nodes/timecode-node.ts @@ -0,0 +1,20 @@ +import { SceneRenderer } from "../scene-renderer"; +import { BaseNode } from "./base-node"; + +export class TimecodeNode extends BaseNode { + async render(renderer: SceneRenderer, time: number) { + renderer.context.fillStyle = "white"; + renderer.context.font = "16px Arial"; + + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time) % 60; + const frame = Math.floor(time * renderer.fps) % renderer.fps; + + const minutesStr = minutes.toString().padStart(2, "0"); + const secondsStr = seconds.toString().padStart(2, "0"); + const frameStr = frame.toString().padStart(2, "0"); + + const text = `${minutesStr}:${secondsStr}:${frameStr}`; + renderer.context.fillText(text, 10, 20); + } +} diff --git a/apps/web/src/lib/renderer/nodes/video-node.ts b/apps/web/src/lib/renderer/nodes/video-node.ts new file mode 100644 index 000000000..4b602617f --- /dev/null +++ b/apps/web/src/lib/renderer/nodes/video-node.ts @@ -0,0 +1,155 @@ +import { + Input, + ALL_FORMATS, + BlobSource, + CanvasSink, + WrappedCanvas, +} from "mediabunny"; + +import { SceneRenderer } from "../scene-renderer"; +import { BaseNode } from "./base-node"; + +const VIDEO_EPSILON = 1 / 1000; +const TIME_FORWARD = 0.5; + +export type VideoNodeParams = { + video: string; + duration: number; + timeOffset: number; + trimStart: number; + trimEnd: number; +}; + +export class VideoNode extends BaseNode { + sink?: CanvasSink; + frameIterator?: AsyncGenerator; + currentFrame?: WrappedCanvas; + + readyPromise: Promise; + + constructor(params: VideoNodeParams) { + super(params); + this.readyPromise = this.load(params.video); + } + + async load(url: string) { + const blob = await fetch(url).then((res) => res.blob()); + const source = new BlobSource(blob); + const input = new Input({ + source, + formats: ALL_FORMATS, + }); + const videoTrack = await input.getPrimaryVideoTrack(); + + if (!videoTrack) { + throw new Error("No video track found"); + } + + if (!(await videoTrack.canDecode())) { + throw new Error("Unable to decode the video track."); + } + + this.sink = new CanvasSink(videoTrack, { + poolSize: 2, + fit: "contain", + rotation: 90, + }); + } + + async startFrameIterator(videoTime: number) { + console.log("starting frame iterator", videoTime); + + if (!this.sink) { + throw new Error("Sink not initialized"); + } + + // Clear previous iterator + if (this.frameIterator) { + await this.frameIterator.return(); + } + + this.frameIterator = this.sink.canvases(videoTime); + + // Return the first frame + const { value: frame } = await this.frameIterator.next(); + + if (!frame) { + throw new Error("No frame found"); + } + + this.currentFrame = frame; + return frame; + } + + getVideoTime(time: number) { + return time - this.params.timeOffset + this.params.trimStart; + } + + isInRange(time: number) { + const videoTime = this.getVideoTime(time); + return ( + videoTime >= this.params.trimStart - VIDEO_EPSILON && + videoTime < this.params.duration - this.params.trimEnd + ); + } + + async getFrameAt(videoTime: number) { + // If not iterator, start one + if (!this.frameIterator) { + return this.startFrameIterator(videoTime); + } + + // If it's current frame, return it + if ( + this.currentFrame && + videoTime >= this.currentFrame.timestamp && + videoTime < this.currentFrame.timestamp + this.currentFrame.duration + ) { + return this.currentFrame; + } + + // If frame near in the iterator, iterate until it + if ( + this.currentFrame && + videoTime >= this.currentFrame.timestamp && + videoTime < this.currentFrame.timestamp + TIME_FORWARD + ) { + while (true) { + const { value: frame } = await this.frameIterator.next(); + if (!frame) { + break; + } + + this.currentFrame = frame; + + if (frame.timestamp >= videoTime) { + return frame; + } + } + } + + // Otherwise, start a new iterator + return this.startFrameIterator(videoTime); + } + + async render(renderer: SceneRenderer, time: number) { + await super.render(renderer, time); + + if (!this.isInRange(time)) { + return; + } + + await this.readyPromise; + + if (!this.sink) { + throw new Error("Sink not initialized"); + } + + const videoTime = this.getVideoTime(time); + const frame = await this.getFrameAt(videoTime); + + if (frame) { + renderer.context.drawImage(frame.canvas, 0, 0); + } + } +} diff --git a/apps/web/src/lib/renderer/scene-exporter.ts b/apps/web/src/lib/renderer/scene-exporter.ts new file mode 100644 index 000000000..e262505b2 --- /dev/null +++ b/apps/web/src/lib/renderer/scene-exporter.ts @@ -0,0 +1,93 @@ +import EventEmitter from "eventemitter3"; + +import { + Output, + Mp4OutputFormat, + BufferTarget, + CanvasSource, +} from "mediabunny"; + +import { SceneNode } from "./nodes/scene-node"; +import { SceneRenderer } from "./scene-renderer"; + +type ExportParams = { + width: number; + height: number; + fps: number; + bitrate?: number; +}; + +const DEFAULT_BITRATE = 4_000_000; + +export type SceneExporterEvents = { + progress: [progress: number]; + complete: [blob: Blob]; + error: [error: Error]; + cancelled: []; +}; + +export class SceneExporter extends EventEmitter { + private renderer: SceneRenderer; + private bitrate: number; + + private cancelled = false; + + constructor(params: ExportParams) { + super(); + this.renderer = new SceneRenderer({ + width: params.width, + height: params.height, + fps: params.fps, + }); + + this.bitrate = params.bitrate ?? DEFAULT_BITRATE; + } + + cancel() { + this.cancelled = true; + } + + async export(scene: SceneNode) { + const { fps } = this.renderer; + const frameCount = Math.ceil(scene.duration * fps); + + const output = new Output({ + format: new Mp4OutputFormat(), + target: new BufferTarget(), + }); + + const videoSource = new CanvasSource(this.renderer.canvas, { + codec: "avc", + bitrate: this.bitrate, + }); + + output.addVideoTrack(videoSource); + + await output.start(); + + for (let i = 0; i < frameCount; i++) { + if (this.cancelled) { + await output.cancel(); + this.emit("cancelled"); + return; + } + + await this.renderer.render(scene, i); + await videoSource.add(i / fps, 1 / fps); + this.emit("progress", i / frameCount); + } + + await output.finalize(); + this.emit("progress", 1); + + const buffer = output.target.buffer; + if (!buffer) { + this.emit("error", new Error("Failed to export video")); + return null; + } + + const blob = new Blob([buffer], { type: "video/mp4" }); + this.emit("complete", blob); + return blob; + } +} diff --git a/apps/web/src/lib/renderer/scene-renderer.ts b/apps/web/src/lib/renderer/scene-renderer.ts new file mode 100644 index 000000000..e4047c178 --- /dev/null +++ b/apps/web/src/lib/renderer/scene-renderer.ts @@ -0,0 +1,64 @@ +import { BaseNode } from "./nodes/base-node"; + +export type SceneRendererParams = { + width: number; + height: number; + fps: number; +}; + +export class SceneRenderer { + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; + + width: number; + height: number; + fps: number; + + constructor(params: SceneRendererParams) { + this.width = params.width; + this.height = params.height; + this.fps = params.fps; + + this.canvas = new OffscreenCanvas(params.width, params.height); + + const context = this.canvas.getContext("2d"); + + if (!context) { + throw new Error("Failed to get canvas context"); + } + + this.context = context; + } + + setSize(width: number, height: number) { + this.canvas = new OffscreenCanvas(width, height); + this.width = width; + this.height = height; + } + + private clear() { + this.context.fillStyle = "black"; + this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + async render(node: BaseNode, frame: number) { + this.clear(); + await node.render(this, frame / this.fps); + } + + async renderToCanvas( + node: BaseNode, + frame: number, + canvas: HTMLCanvasElement + ) { + await this.render(node, frame); + + const ctx = canvas.getContext("2d")!; + + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + ctx.drawImage(this.canvas, 0, 0, canvas.width, canvas.height); + } +} diff --git a/apps/web/src/stores/renderer-store.ts b/apps/web/src/stores/renderer-store.ts new file mode 100644 index 000000000..36d7db4fe --- /dev/null +++ b/apps/web/src/stores/renderer-store.ts @@ -0,0 +1,12 @@ +import { create } from "zustand"; +import { SceneNode } from "@/lib/renderer/nodes/scene-node"; + +interface RendererState { + scene: SceneNode | null; + setScene: (scene: SceneNode | null) => void; +} + +export const useRendererStore = create((set) => ({ + scene: null, + setScene: (scene: SceneNode | null) => set({ scene }), +})); diff --git a/bun.lock b/bun.lock index ea7c8c9a1..4e78286d5 100644 --- a/bun.lock +++ b/bun.lock @@ -43,10 +43,12 @@ "dotenv": "^16.5.0", "drizzle-orm": "^0.44.2", "embla-carousel-react": "^8.5.1", + "eventemitter3": "^5.0.1", "feed": "^5.1.0", "framer-motion": "^11.13.1", "input-otp": "^1.4.1", "lucide-react": "^0.468.0", + "mediabunny": "^1.11.2", "motion": "^12.18.1", "nanoid": "^5.1.5", "next": "^15.4.5", @@ -71,6 +73,7 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", + "use-deep-compare-effect": "^1.8.1", "vaul": "^1.1.1", "zod": "^3.25.67", "zustand": "^5.0.2", @@ -532,7 +535,7 @@ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="], - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], @@ -558,6 +561,10 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -754,7 +761,7 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], @@ -896,6 +903,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mediabunny": ["mediabunny@1.11.2", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-dZpaq+YMKo5dUz6HlwWtZJfKPf3rZCq9BXurC7/zVmgjB3sTW5l32VT65gWm/ojv0vUwH+WbtolkmOQX1viz5g=="], + "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=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1192,6 +1201,8 @@ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-deep-compare-effect": ["use-deep-compare-effect@1.8.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "dequal": "^2.0.2" }, "peerDependencies": { "react": ">=16.13" } }, "sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], @@ -1250,6 +1261,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], + "better-auth/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "motion/framer-motion": ["framer-motion@12.23.6", "", { "dependencies": { "motion-dom": "^12.23.6", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw=="], @@ -1268,6 +1281,8 @@ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], @@ -1316,14 +1331,16 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "@types/bun/bun-types/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.23.6", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w=="], "motion/framer-motion/motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], - "opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "opencut/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "opencut/next/@next/env": ["@next/env@15.4.5", "", {}, "sha512-ruM+q2SCOVCepUiERoxOmZY9ZVoecR3gcXNwCYZRvQQWRjhOiPJGmQ2fAiLR6YKWXcSAh7G79KEFxN3rwhs4LQ=="], "opencut/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-84dAN4fkfdC7nX6udDLz9GzQlMUwEMKD7zsseXrl7FTeIItF8vpk1lhLEnsotiiDt+QFu3O1FVWnqwcRD2U3KA=="], @@ -1344,6 +1361,8 @@ "opencut/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "opencut/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], } }