diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-30 18:17:45 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-30 18:18:15 +0200 |
| commit | ed02ba872c02eafed45f406c68d389d0a1112a4e (patch) | |
| tree | 40530d77cfe306621915f067c66531cb9e783a4d /apps/web/src | |
| parent | e837e7504379e100ff3b56282577ec67bb88bec9 (diff) | |
| download | opencut-ed02ba872c02eafed45f406c68d389d0a1112a4e.tar.gz | |
feat: Electron desktop app, native ffmpeg export, fully static web
- apps/desktop: Electron shell serving the static web build over a
privileged opencut:// protocol; exports stream frames over IPC to a
bundled ffmpeg (libx264/libvpx-vp9, BT.709 tagged) with backpressure.
electron-builder packaging: AppImage, deb, rpm.
- apps/web: dead backend deleted (better-auth, drizzle/pg, upstash,
botid, freesound proxy, blog, rss, env validation); next build emits
a zero-env static export; /editor/[project_id] -> /editor?project=.
- Native exporter mirrors SceneExporter; activates when the preload
bridge is present; web keeps the mediabunny path.
- Docker self-hosting now builds and serves the static export via nginx.
- Release workflow: v* tags publish static web tarball + Linux packages.
- Changelog 0.4.0; README/AGENTS/CONTRIBUTING/roadmap updated.
- GPUI/Rust rewrite parked on branch rust-rewrite.
Diffstat (limited to 'apps/web/src')
27 files changed, 345 insertions, 1038 deletions
diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts deleted file mode 100644 index edf0d0c2..00000000 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { auth } from "@/auth/server";
-import { toNextJsHandler } from "better-auth/next-js";
-
-export const { POST, GET } = toNextJsHandler(auth);
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts deleted file mode 100644 index dbe6fdb1..00000000 --- a/apps/web/src/app/api/health/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -export async function GET() {
- return new Response("OK", { status: 200 });
-}
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts deleted file mode 100644 index aa7a1b60..00000000 --- a/apps/web/src/app/api/sounds/search/route.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { webEnv } from "@/env/web"; -import { type NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { checkRateLimit } from "@/auth/rate-limit"; - -const searchParamsSchema = z.object({ - q: z.string().max(500, "Query too long").optional(), - type: z.enum(["songs", "effects"]).optional(), - page: z.coerce.number().int().min(1).max(1000).default(1), - page_size: z.coerce.number().int().min(1).max(150).default(20), - sort: z - .enum(["downloads", "rating", "created", "score"]) - .default("downloads"), - min_rating: z.coerce.number().min(0).max(5).default(3), - commercial_only: z.coerce.boolean().default(true), -}); - -const freesoundResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string().url(), - previews: z - .object({ - "preview-hq-mp3": z.string().url(), - "preview-lq-mp3": z.string().url(), - "preview-hq-ogg": z.string().url(), - "preview-lq-ogg": z.string().url(), - }) - .optional(), - download: z.string().url().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - num_downloads: z.number().optional(), - avg_rating: z.number().optional(), - num_ratings: z.number().optional(), -}); - -const freesoundResponseSchema = z.object({ - count: z.number(), - next: z.string().url().nullable(), - previous: z.string().url().nullable(), - results: z.array(freesoundResultSchema), -}); - -const transformedResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string(), - previewUrl: z.string().optional(), - downloadUrl: z.string().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - downloads: z.number().optional(), - rating: z.number().optional(), - ratingCount: z.number().optional(), -}); - -const apiResponseSchema = z.object({ - count: z.number(), - next: z.string().nullable(), - previous: z.string().nullable(), - results: z.array(transformedResultSchema), - query: z.string().optional(), - type: z.string(), - page: z.number(), - pageSize: z.number(), - sort: z.string(), - minRating: z.number().optional(), -}); - -function buildSortParameter({ query, sort }: { query?: string; sort: string }) { - if (!query) return `${sort}_desc`; - return sort === "score" ? "score" : `${sort}_desc`; -} - -function applyEffectsFilters({ - params, - min_rating, - commercial_only, -}: { - params: URLSearchParams; - min_rating: number; - commercial_only: boolean; -}) { - params.append("filter", "duration:[* TO 30.0]"); - params.append("filter", `avg_rating:[${min_rating} TO *]`); - - if (commercial_only) { - params.append( - "filter", - 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")', - ); - } - - params.append( - "filter", - "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion", - ); -} - -function transformFreesoundResult( - result: z.infer<typeof freesoundResultSchema>, -) { - return { - id: result.id, - name: result.name, - description: result.description, - url: result.url, - previewUrl: - result.previews?.["preview-hq-mp3"] || - result.previews?.["preview-lq-mp3"], - downloadUrl: result.download, - duration: result.duration, - filesize: result.filesize, - type: result.type, - channels: result.channels, - bitrate: result.bitrate, - bitdepth: result.bitdepth, - samplerate: result.samplerate, - username: result.username, - tags: result.tags, - license: result.license, - created: result.created, - downloads: result.num_downloads || 0, - rating: result.avg_rating || 0, - ratingCount: result.num_ratings || 0, - }; -} - -export async function GET(request: NextRequest) { - try { - const { limited } = await checkRateLimit({ request }); - if (limited) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const { searchParams } = new URL(request.url); - - const validationResult = searchParamsSchema.safeParse({ - q: searchParams.get("q") || undefined, - type: searchParams.get("type") || undefined, - page: searchParams.get("page") || undefined, - page_size: searchParams.get("page_size") || undefined, - sort: searchParams.get("sort") || undefined, - min_rating: searchParams.get("min_rating") || undefined, - }); - - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 }, - ); - } - - const { - q: query, - type, - page, - page_size: pageSize, - sort, - min_rating, - commercial_only, - } = validationResult.data; - - if (type === "songs") { - return NextResponse.json( - { - error: "Songs are not available yet", - message: - "Song search functionality is coming soon. Try searching for sound effects instead.", - }, - { status: 501 }, - ); - } - - const baseUrl = "https://freesound.org/apiv2/search/text/"; - - const sortParam = buildSortParameter({ query, sort }); - - const params = new URLSearchParams({ - query: query || "", - token: webEnv.FREESOUND_API_KEY, - page: page.toString(), - page_size: pageSize.toString(), - sort: sortParam, - fields: - "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", - }); - - const isEffectsSearch = type === "effects" || !type; - if (isEffectsSearch) { - applyEffectsFilters({ params, min_rating, commercial_only }); - } - - const response = await fetch(`${baseUrl}?${params.toString()}`); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Freesound API error:", response.status, errorText); - return NextResponse.json( - { error: "Failed to search sounds" }, - { status: response.status }, - ); - } - - const rawData = await response.json(); - - const freesoundValidation = freesoundResponseSchema.safeParse(rawData); - if (!freesoundValidation.success) { - console.error( - "Invalid Freesound API response:", - freesoundValidation.error, - ); - return NextResponse.json( - { error: "Invalid response from Freesound API" }, - { status: 502 }, - ); - } - - const data = freesoundValidation.data; - - const transformedResults = data.results.map(transformFreesoundResult); - - const responseData = { - count: data.count, - next: data.next, - previous: data.previous, - results: transformedResults, - query: query || "", - type: type || "effects", - page, - pageSize, - sort, - minRating: min_rating, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error, - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 }, - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Error searching sounds:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx deleted file mode 100644 index dba9b504..00000000 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { Metadata } from "next";
-import Image from "next/image";
-import { notFound } from "next/navigation";
-import { BasePage } from "@/app/base-page";
-import Prose from "@/components/ui/prose";
-import { Separator } from "@/components/ui/separator";
-import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
-import type { Author, Post } from "@/blog/types";
-
-type PageProps = {
- params: Promise<{ slug: string }>;
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
-};
-
-export async function generateMetadata({
- params,
-}: PageProps): Promise<Metadata> {
- const slug = (await params).slug;
-
- const data = await getSinglePost({ slug });
-
- if (!data || !data.post) return {};
-
- return {
- title: data.post.title,
- description: data.post.description,
- twitter: {
- title: `${data.post.title}`,
- description: `${data.post.description}`,
- card: "summary_large_image",
- images: [
- {
- url: data.post.coverImage,
- width: "1200",
- height: "630",
- alt: data.post.title,
- },
- ],
- },
- openGraph: {
- type: "article",
- images: [
- {
- url: data.post.coverImage,
- width: "1200",
- height: "630",
- alt: data.post.title,
- },
- ],
- title: data.post.title,
- description: data.post.description,
- publishedTime: new Date(data.post.publishedAt).toISOString(),
- authors: data.post.authors.map((author: Author) => author.name),
- },
- };
-}
-
-export async function generateStaticParams() {
- const data = await getPosts();
- if (!data || !data.posts.length) return [];
-
- return data.posts.map((post) => ({
- slug: post.slug,
- }));
-}
-
-export default async function BlogPostPage({ params }: PageProps) {
- const slug = (await params).slug;
- const data = await getSinglePost({ slug });
- if (!data || !data.post) return notFound();
-
- const html = await processHtmlContent({ html: data.post.content });
-
- return (
- <BasePage>
- <PostHeader post={data.post} />
- <Separator />
- <PostContent html={html} />
- </BasePage>
- );
-}
-
-function PostHeader({ post }: { post: Post }) {
- const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
- day: "numeric",
- month: "long",
- year: "numeric",
- });
-
- return (
- <div className="flex flex-col items-center justify-center gap-8">
- <PostMeta date={formattedDate} publishedAt={post.publishedAt} />
- <PostTitle title={post.title} />
- {post.coverImage && <PostCoverImage post={post} />}
- </div>
- );
-}
-
-function PostCoverImage({ post }: { post: Post }) {
- return (
- <div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
- <Image
- src={post.coverImage}
- alt={post.title}
- loading="eager"
- fill
- className="rounded-lg object-cover"
- />
- </div>
- );
-}
-
-function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
- return (
- <div className="flex items-center justify-center">
- <time dateTime={publishedAt.toString()}>{date}</time>
- </div>
- );
-}
-
-function PostTitle({ title }: { title: string }) {
- return (
- <h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
- {title}
- </h1>
- );
-}
-
-function PostContent({ html }: { html: string }) {
- return (
- <section className="">
- <Prose html={html} />
- </section>
- );
-}
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx deleted file mode 100644 index b40bc2fc..00000000 --- a/apps/web/src/app/blog/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { Metadata } from "next";
-import Link from "next/link";
-import { BasePage } from "@/app/base-page";
-import { Separator } from "@/components/ui/separator";
-import { getPosts } from "@/blog/query";
-import type { Post } from "@/blog/types";
-
-export const metadata: Metadata = {
- title: "Blog - OpenCut",
- description:
- "Read the latest news and updates about OpenCut, the free and open-source video editor.",
- openGraph: {
- title: "Blog - OpenCut",
- description:
- "Read the latest news and updates about OpenCut, the free and open-source video editor.",
- type: "website",
- },
-};
-
-export default async function BlogPage() {
- const data = await getPosts().catch(() => null);
- if (!data || !data.posts) return <div>No posts yet</div>;
-
- return (
- <BasePage
- title="Blog"
- description="Read the latest news and updates about OpenCut, the free and open-source video editor."
- >
- <div className="flex flex-col">
- {data.posts.map((post) => (
- <div key={post.id} className="flex flex-col">
- <BlogPostItem post={post} />
- <Separator />
- </div>
- ))}
- </div>
- </BasePage>
- );
-}
-
-function BlogPostItem({ post }: { post: Post }) {
- return (
- <Link href={`/blog/${post.slug}`}>
- <div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
- <div className="flex flex-col gap-2">
- <h2 className="text-xl font-semibold">{post.title}</h2>
- <p className="text-muted-foreground">{post.description}</p>
- </div>
- </div>
- </Link>
- );
-}
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx index 6c529ce1..b6623cf3 100644 --- a/apps/web/src/app/changelog/[version]/page.tsx +++ b/apps/web/src/app/changelog/[version]/page.tsx @@ -15,6 +15,8 @@ import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button" type Props = { params: Promise<{ version: string }> };
+export const dynamicParams = false;
+
export async function generateStaticParams() {
return getSortedReleases().map((release) => ({ version: release.version }));
}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/page.tsx index b759b9c2..8229774a 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { ResizablePanelGroup, ResizablePanel, @@ -17,7 +17,7 @@ import { MigrationDialog } from "@/project/components/migration-dialog"; import { usePanelStore } from "@/editor/panel-store"; import { usePasteMedia } from "@/media/use-paste-media"; import { MobileGate } from "@/components/editor/mobile-gate"; -import { useMemo, useState } from "react"; +import { Suspense, useMemo, useState } from "react"; import { useEditor } from "@/editor/use-editor"; import { Cancel01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -36,8 +36,16 @@ import { } from "@/timeline/bookmarks/index"; export default function Editor() { - const params = useParams<{ project_id: string }>(); - const projectId = params.project_id; + return ( + <Suspense> + <EditorView /> + </Suspense> + ); +} + +function EditorView() { + const searchParams = useSearchParams(); + const projectId = searchParams.get("project") ?? ""; return ( <MobileGate> diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 558ae538..9713d9d5 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,21 +5,12 @@ import { Toaster } from "../components/ui/sonner"; import { ChangelogNotification } from "@/changelog/components/changelog-notification"; import { TooltipProvider } from "../components/ui/tooltip"; import { baseMetaData } from "./metadata"; -import { BotIdClient } from "botid/client"; -import { webEnv } from "@/env/web"; import { Inter } from "next/font/google"; const siteFont = Inter({ subsets: ["latin"] }); export const metadata = baseMetaData; -const protectedRoutes = [ - { - path: "/none", - method: "GET", - }, -]; - export default function RootLayout({ children, }: Readonly<{ @@ -28,7 +19,6 @@ export default function RootLayout({ return ( <html lang="en" suppressHydrationWarning> <head> - <BotIdClient protect={protectedRoutes} /> {process.env.NODE_ENV === "development" && ( <> <Script @@ -52,7 +42,7 @@ export default function RootLayout({ strategy="afterInteractive" async data-client-id="UP-Wcoy5arxFeK7oyjMMZ" - data-disabled={webEnv.NODE_ENV === "development"} + data-disabled={process.env.NODE_ENV === "development"} data-track-attributes={false} data-track-errors={true} data-track-outgoing-links={false} diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index d238af9e..7cf50c93 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -515,7 +515,7 @@ function NewProjectButton() { const projectId = await editor.project.createNewProject({ name: "New project", }); - router.push(`/editor/${projectId}`); + router.push(`/editor?project=${projectId}`); }; return ( @@ -553,7 +553,7 @@ function ImportProjectButton() { try { const projectId = await editor.project.importArchive({ file }); if (projectId) { - router.push(`/editor/${projectId}`); + router.push(`/editor?project=${projectId}`); } } finally { setIsImporting(false); @@ -727,7 +727,7 @@ function ProjectItem({ className="size-5 shrink-0" /> - <Link href={`/editor/${project.id}`} className="flex-1 min-w-0"> + <Link href={`/editor?project=${project.id}`} className="flex-1 min-w-0"> {listRowContent} </Link> @@ -753,7 +753,7 @@ function ProjectItem({ <div className="group relative"> {isGridView ? ( <> - <Link href={`/editor/${project.id}`} className="block"> + <Link href={`/editor?project=${project.id}`} className="block"> {gridContent} </Link> @@ -1043,7 +1043,7 @@ function EmptyState() { const projectId = await editor.project.createNewProject({ name: "New project", }); - router.push(`/editor/${projectId}`); + router.push(`/editor?project=${projectId}`); } catch (error) { toast.error("Failed to create project", { description: diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts index 1b1ca865..6f316d98 100644 --- a/apps/web/src/app/robots.ts +++ b/apps/web/src/app/robots.ts @@ -1,6 +1,8 @@ import type { MetadataRoute } from "next";
import { SITE_URL } from "@/site/brand";
+export const dynamic = "force-static";
+
export default function robots(): MetadataRoute.Robots {
return {
rules: {
diff --git a/apps/web/src/app/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts deleted file mode 100644 index d4e9570a..00000000 --- a/apps/web/src/app/rss.xml/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Feed } from "feed";
-import { getPosts } from "@/blog/query";
-import { SITE_INFO, SITE_URL } from "@/site/brand";
-
-export async function GET() {
- try {
- const { posts } = await getPosts();
-
- const feed = new Feed({
- title: `${SITE_INFO.title} Blog`,
- description: SITE_INFO.description,
- id: `${SITE_URL}`,
- link: `${SITE_URL}/blog/`,
- language: "en",
- image: `${SITE_INFO.openGraphImage}`,
- favicon: `${SITE_INFO.favicon}`,
- copyright: `All rights reserved ${new Date().getFullYear()}, ${
- SITE_INFO.title
- }`,
- });
-
- for (const post of posts) {
- feed.addItem({
- title: post.title,
- id: `${SITE_URL}/blog/${post.slug}`,
- link: `${SITE_URL}/blog/${post.slug}`,
- description: post.description,
- author: post.authors.map((author) => ({
- name: author.name,
- })),
- date: new Date(post.publishedAt),
- image: post.coverImage || SITE_INFO.openGraphImage,
- });
- }
-
- return new Response(feed.rss2(), {
- headers: {
- "Content-Type": "text/xml",
- "Cache-Control": "public, max-age=86400, stale-while-revalidate",
- },
- });
- } catch (error) {
- console.error("Error generating RSS feed", error);
- return new Response("Internal Server Error", { status: 500 });
- }
-}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 73b4180f..a473d2d9 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -1,61 +1,39 @@ -import { SITE_URL } from "@/site/brand";
-import { getPosts } from "@/blog/query";
-import type { MetadataRoute } from "next";
-
-export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
- const data = await getPosts();
-
- const postPages: MetadataRoute.Sitemap =
- data?.posts?.map((post) => ({
- url: `${SITE_URL}/blog/${post.slug}`,
- lastModified: new Date(post.publishedAt),
- changeFrequency: "weekly",
- priority: 0.8,
- })) ?? [];
-
- return [
- {
- url: SITE_URL,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/contributors`,
- lastModified: new Date(),
- changeFrequency: "daily",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/roadmap`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/privacy`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/terms`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/why-not-capcut`,
- lastModified: new Date(),
- changeFrequency: "yearly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/blog`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- ...postPages,
- ];
-}
+import { SITE_URL } from "@/site/brand"; +import type { MetadataRoute } from "next"; + +export const dynamic = "force-static"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { + url: SITE_URL, + lastModified: new Date(), + changeFrequency: "weekly", + priority: 1, + }, + { + url: `${SITE_URL}/contributors`, + lastModified: new Date(), + changeFrequency: "daily", + priority: 0.5, + }, + { + url: `${SITE_URL}/roadmap`, + lastModified: new Date(), + changeFrequency: "weekly", + priority: 1, + }, + { + url: `${SITE_URL}/privacy`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.5, + }, + { + url: `${SITE_URL}/terms`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.5, + }, + ]; +} diff --git a/apps/web/src/auth/client.ts b/apps/web/src/auth/client.ts deleted file mode 100644 index 58be51c3..00000000 --- a/apps/web/src/auth/client.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createAuthClient } from "better-auth/react"; -import { webEnv } from "@/env/web"; - -export const { signIn, signUp, useSession } = createAuthClient({ - baseURL: webEnv.NEXT_PUBLIC_SITE_URL, -}); diff --git a/apps/web/src/auth/rate-limit.ts b/apps/web/src/auth/rate-limit.ts deleted file mode 100644 index c10c3819..00000000 --- a/apps/web/src/auth/rate-limit.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { webEnv } from "@/env/web"; - -const redis = new Redis({ - url: webEnv.UPSTASH_REDIS_REST_URL, - token: webEnv.UPSTASH_REDIS_REST_TOKEN, -}); - -export const baseRateLimit = new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute - analytics: true, - prefix: "rate-limit", -}); - -export async function checkRateLimit({ request }: { request: Request }) { - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - return { success, limited: !success }; -} diff --git a/apps/web/src/auth/server.ts b/apps/web/src/auth/server.ts deleted file mode 100644 index 5e49387a..00000000 --- a/apps/web/src/auth/server.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { betterAuth, type RateLimit } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { Redis } from "@upstash/redis"; -import { db } from "@/db"; -import { webEnv } from "@/env/web"; - -const redis = new Redis({ - url: webEnv.UPSTASH_REDIS_REST_URL, - token: webEnv.UPSTASH_REDIS_REST_TOKEN, -}); - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - usePlural: true, - }), - secret: webEnv.BETTER_AUTH_SECRET, - user: { - deleteUser: { - enabled: true, - }, - }, - emailAndPassword: { - enabled: true, - }, - rateLimit: { - storage: "secondary-storage", - customStorage: { - get: async (key) => { - const value = await redis.get(key); - return value as RateLimit | undefined; - }, - set: async (key, value) => { - await redis.set(key, value); - }, - }, - }, - baseURL: webEnv.NEXT_PUBLIC_SITE_URL, - appName: "OpenCut", - trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL], -}); - -export type Auth = typeof auth; diff --git a/apps/web/src/blog/query.ts b/apps/web/src/blog/query.ts deleted file mode 100644 index a7742db2..00000000 --- a/apps/web/src/blog/query.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type {
- MarbleAuthorList,
- MarbleCategoryList,
- Pagination,
- MarblePost,
- MarblePostList,
- MarbleTagList,
-} from "@/blog/types";
-import { unified } from "unified";
-import rehypeParse from "rehype-parse";
-import rehypeStringify from "rehype-stringify";
-import rehypeSlug from "rehype-slug";
-import rehypeAutolinkHeadings from "rehype-autolink-headings";
-import rehypeSanitize from "rehype-sanitize";
-
-const url =
- process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
-const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
-
-const EMPTY_PAGINATION: Pagination = {
- limit: 0,
- currpage: 1,
- nextPage: null,
- prevPage: null,
- totalItems: 0,
- totalPages: 0,
-};
-
-const EMPTY_POSTS: MarblePostList = {
- posts: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_TAGS: MarbleTagList = {
- tags: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_CATEGORIES: MarbleCategoryList = {
- categories: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_AUTHORS: MarbleAuthorList = {
- authors: [],
- pagination: EMPTY_PAGINATION,
-};
-
-function isMarbleConfigured() {
- return ![
- "",
- "placeholder",
- "build-placeholder",
- "your_workspace_key_here",
- ].includes(key);
-}
-
-async function fetchFromMarble<T>({
- endpoint,
- fallback,
-}: {
- endpoint: string;
- fallback: T;
-}): Promise<T> {
- if (!isMarbleConfigured()) {
- return fallback;
- }
-
- try {
- const response = await fetch(`${url}/${key}/${endpoint}`);
- if (!response.ok) {
- console.warn(
- `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`,
- );
- return fallback;
- }
- return (await response.json()) as T;
- } catch (error) {
- console.error(`Error fetching ${endpoint}:`, error);
- return fallback;
- }
-}
-
-export async function getPosts() {
- return fetchFromMarble<MarblePostList>({
- endpoint: "posts",
- fallback: EMPTY_POSTS,
- });
-}
-
-export async function getTags() {
- return fetchFromMarble<MarbleTagList>({
- endpoint: "tags",
- fallback: EMPTY_TAGS,
- });
-}
-
-export async function getSinglePost({ slug }: { slug: string }) {
- return fetchFromMarble<MarblePost | null>({
- endpoint: `posts/${slug}`,
- fallback: null,
- });
-}
-
-export async function getCategories() {
- return fetchFromMarble<MarbleCategoryList>({
- endpoint: "categories",
- fallback: EMPTY_CATEGORIES,
- });
-}
-
-export async function getAuthors() {
- return fetchFromMarble<MarbleAuthorList>({
- endpoint: "authors",
- fallback: EMPTY_AUTHORS,
- });
-}
-
-export async function processHtmlContent({
- html,
-}: {
- html: string;
-}): Promise<string> {
- const processor = unified()
- .use(rehypeSanitize)
- .use(rehypeParse, { fragment: true })
- .use(rehypeSlug)
- .use(rehypeAutolinkHeadings, { behavior: "append" })
- .use(rehypeStringify);
-
- const file = await processor.process({ value: html, type: "html" });
- return String(file);
-}
diff --git a/apps/web/src/blog/types.ts b/apps/web/src/blog/types.ts deleted file mode 100644 index 7b2f450a..00000000 --- a/apps/web/src/blog/types.ts +++ /dev/null @@ -1,92 +0,0 @@ -export type Post = {
- id: string;
- slug: string;
- title: string;
- content: string;
- description: string;
- coverImage: string;
- publishedAt: Date;
- updatedAt: Date;
- authors: {
- id: string;
- name: string;
- image: string;
- }[];
- category: {
- id: string;
- slug: string;
- name: string;
- };
- tags: {
- id: string;
- slug: string;
- name: string;
- }[];
- attribution: {
- author: string;
- url: string;
- } | null;
-};
-
-export type Pagination = {
- limit: number;
- currpage: number;
- nextPage: number | null;
- prevPage: number | null;
- totalItems: number;
- totalPages: number;
-};
-
-export type MarblePostList = {
- posts: Post[];
- pagination: Pagination;
-};
-
-export type MarblePost = {
- post: Post;
-};
-
-export type Tag = {
- id: string;
- name: string;
- slug: string;
-};
-
-export type MarbleTag = {
- tag: Tag;
-};
-
-export type MarbleTagList = {
- tags: Tag[];
- pagination: Pagination;
-};
-
-export type Category = {
- id: string;
- name: string;
- slug: string;
-};
-
-export type MarbleCategory = {
- category: Category;
-};
-
-export type MarbleCategoryList = {
- categories: Category[];
- pagination: Pagination;
-};
-
-export type Author = {
- id: string;
- name: string;
- image: string;
-};
-
-export type MarbleAuthor = {
- author: Author;
-};
-
-export type MarbleAuthorList = {
- authors: Author[];
- pagination: Pagination;
-};
diff --git a/apps/web/src/changelog/entries/0.4.0.md b/apps/web/src/changelog/entries/0.4.0.md index 141df133..8d9e32da 100644 --- a/apps/web/src/changelog/entries/0.4.0.md +++ b/apps/web/src/changelog/entries/0.4.0.md @@ -1,19 +1,35 @@ --- -version: "0.3.0" -date: "2026-03-04" -published: false -title: "Title" -description: "Description" +version: "0.4.0" +date: "2026-07-30" +published: true +title: "Desktop app, native exports & fully static web" +description: "OpenCut is now a real desktop app for Linux with native ffmpeg-powered exports, and the web version runs anywhere as plain static files — no server, no account, no backend." +summary: "Linux desktop app (AppImage, deb, rpm), native x264/VP9 exports, static web build, project versioning, .ocp files, voice-over." changes: -- type: new - text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight." -- type: new - text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke." -- type: fixed - text: "Preview panning now keeps working while the Masks tab is open." -- type: new - text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position." -- type: improved - text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top." -- type: fixed - text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio."
\ No newline at end of file + - type: new + text: "Desktop app for Linux. The full editor as a self-contained native app, distributed as AppImage, .deb, and .rpm. Download, run, edit — no browser required." + - type: new + text: "Native export engine in the desktop app. Exports are encoded by a bundled ffmpeg with libx264 (H.264) and libvpx-vp9 (VP9) on a proper quality ladder, instead of the browser's built-in encoder. MP4 exports now actually hit the quality you picked." + - type: fixed + text: "Browser MP4 exports silently undershot the selected bitrate — 'high quality' came out at a fraction of the intended bitrate. The desktop app's native encoder honors it, and colors are now correctly tagged as BT.709." + - type: new + text: "The web version is now a fully static site. No server, no database, no signup — it can be hosted anywhere as plain files, and everything still lives locally in your browser." + - type: new + text: "Project versioning. Create named checkpoints of a project, and restore any of them later — restoring first creates a safety checkpoint of the current state. Media files are pinned per version." + - type: new + text: "Single-file projects. Export an entire project — timeline, settings, and all media — as one .ocp file, and import it back anywhere." + - type: new + text: "Voice-over recording. Record from your microphone straight onto the timeline at the playhead." + - type: new + text: "Freeze frame. One click captures the current frame at the playhead and inserts it as a still." + - type: improved + text: "Video clips in the timeline now render real per-position thumbnails that adapt their density to the zoom level." + - type: improved + text: "Ripple-extend: dragging the edge of a static element (image, text, freeze still) past its neighbor pushes the whole chain of clips after it, instead of stopping at the boundary." + - type: improved + text: "Export encoding is frame-rate aware when computing bitrate, and keyframes are placed every 2 seconds for smoother scrubbing in players." + - type: technical + text: "The backend is gone. Authentication, database, rate limiting, and the blog were dead weight — the editor was already fully client-side on IndexedDB, so the entire server surface was deleted. `next build` now emits a static export with zero environment variables." + - type: technical + text: "The desktop shell is Electron serving the static web build over a privileged `opencut://` protocol. Exports stream raw frames over IPC to a bundled ffmpeg with full backpressure, so memory stays flat regardless of project length." +--- diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index e738fb68..5e724384 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -34,6 +34,8 @@ import { } from "@/components/section";
import { useEditor } from "@/editor/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
+import { getDesktopBridge } from "@/desktop/bridge";
+import { toast } from "sonner";
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
@@ -128,6 +130,22 @@ function ExportPopover({ return;
}
+ const filePath = result.success ? result.filePath : undefined;
+ if (result.success && filePath) {
+ toast.success(`Export saved to ${filePath}`, {
+ action: {
+ label: "Reveal",
+ onClick: () => {
+ getDesktopBridge()?.revealFile(filePath);
+ },
+ },
+ });
+
+ editor.project.clearExportState();
+ onOpenChange(false);
+ return;
+ }
+
if (result.success && result.buffer) {
downloadBuffer({
buffer: result.buffer,
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index fdc84af0..96c8a306 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -59,7 +59,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { const newProjectId = await editor.project.createNewProject({ name: "Untitled Project", }); - router.replace(`/editor/${newProjectId}`); + router.replace(`/editor?project=${newProjectId}`); } catch (_createErr) { setError("Failed to create project"); setIsLoading(false); diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index d73e3e58..29324eed 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -3,6 +3,8 @@ import type { RootNode } from "@/services/renderer/nodes/root-node"; import type { ExportOptions, ExportResult } from "@/export"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import { SceneExporter } from "@/services/renderer/scene-exporter"; +import { NativeExporter } from "@/services/renderer/native-exporter"; +import { getDesktopBridge } from "@/desktop/bridge"; import { buildScene } from "@/services/renderer/scene-builder"; import { createTimelineAudioBuffer } from "@/media/audio"; import { formatTimecode } from "opencut-wasm"; @@ -214,7 +216,7 @@ export class RendererManager { background: activeProject.settings.background, }); - const exporter = new SceneExporter({ + const exportParams = { width: exportSize.width, height: exportSize.height, fps: exportFps, @@ -222,7 +224,33 @@ export class RendererManager { quality, shouldIncludeAudio: !!includeAudio, audioBuffer: audioBuffer || undefined, - }); + }; + + type ProjectExporter = { + on( + event: "progress", + listener: (progress: number) => void, + ): unknown; + cancel(): void; + export({ + rootNode, + }: { + rootNode: RootNode; + }): Promise<ArrayBuffer | string | null>; + }; + + let exporter: ProjectExporter; + if (getDesktopBridge()) { + const safeName = + activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() || + "export"; + exporter = new NativeExporter({ + ...exportParams, + fileName: `${safeName}.${format}`, + }); + } else { + exporter = new SceneExporter(exportParams); + } exporter.on("progress", (progress) => { const adjustedProgress = includeAudio @@ -242,20 +270,24 @@ export class RendererManager { const cancelInterval = setInterval(checkCancel, 100); try { - const buffer = await exporter.export({ rootNode: scene }); + const result = await exporter.export({ rootNode: scene }); clearInterval(cancelInterval); if (cancelled) { return { success: false, cancelled: true }; } - if (!buffer) { + if (typeof result === "string") { + return { success: true, filePath: result }; + } + + if (!result) { return { success: false, error: "Export failed to produce buffer" }; } return { success: true, - buffer, + buffer: result, }; } finally { clearInterval(cancelInterval); diff --git a/apps/web/src/db/index.ts b/apps/web/src/db/index.ts deleted file mode 100644 index 29e3ffda..00000000 --- a/apps/web/src/db/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import * as schema from "./schema"; -import { webEnv } from "@/env/web"; - -let _db: ReturnType<typeof drizzle> | null = null; - -function getDb() { - if (!_db) { - const client = postgres(webEnv.DATABASE_URL); - _db = drizzle(client, { schema }); - } - - return _db; -} - -export const db = getDb(); - -export * from "./schema"; diff --git a/apps/web/src/db/schema.ts b/apps/web/src/db/schema.ts deleted file mode 100644 index 8f8be248..00000000 --- a/apps/web/src/db/schema.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"; - -export const users = pgTable("users", { - id: text("id").primaryKey(), - - // todo: implement fully anonymous sign-in for privacy - // we don't have any auth flows currently so this is fine for now - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").default(false).notNull(), - image: text("image"), - createdAt: timestamp("created_at") - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), - updatedAt: timestamp("updated_at") - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), -}).enableRLS(); - -export const sessions = pgTable("sessions", { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at").notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), -}).enableRLS(); - -export const accounts = pgTable("accounts", { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at").notNull(), -}).enableRLS(); - -export const feedback = pgTable("feedback", { - id: text("id").primaryKey(), - message: text("message").notNull(), - createdAt: timestamp("created_at") - .$defaultFn(() => new Date()) - .notNull(), -}); - -export const verifications = pgTable("verifications", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").$defaultFn( - () => /* @__PURE__ */ new Date(), - ), - updatedAt: timestamp("updated_at").$defaultFn( - () => /* @__PURE__ */ new Date(), - ), -}).enableRLS(); diff --git a/apps/web/src/desktop/bridge.ts b/apps/web/src/desktop/bridge.ts new file mode 100644 index 00000000..ea9f48f3 --- /dev/null +++ b/apps/web/src/desktop/bridge.ts @@ -0,0 +1,35 @@ +export type DesktopExportBegin = { + width: number; + height: number; + fpsNumerator: number; + fpsDenominator: number; + format: "mp4" | "webm"; + quality: "low" | "medium" | "high" | "very_high"; + hasAudio: boolean; + sampleRate: number; + channels: number; + defaultFileName: string; +}; + +export type OpenCutDesktop = { + isDesktop: true; + exportBegin: ( + opts: DesktopExportBegin, + ) => Promise<{ id: string; filePath: string } | null>; + exportWriteAudio: (id: string, pcm: ArrayBuffer) => Promise<void>; + exportWriteFrame: (id: string, rgba: ArrayBuffer) => Promise<void>; + exportFinish: (id: string) => Promise<void>; + exportCancel: (id: string) => Promise<void>; + revealFile: (path: string) => Promise<void>; +}; + +declare global { + interface Window { + opencutDesktop?: OpenCutDesktop; + } +} + +export function getDesktopBridge(): OpenCutDesktop | null { + if (typeof window === "undefined") return null; + return window.opencutDesktop ?? null; +} diff --git a/apps/web/src/env/web.ts b/apps/web/src/env/web.ts deleted file mode 100644 index aa051615..00000000 --- a/apps/web/src/env/web.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; - -const webEnvSchema = z.object({ - // Node - NODE_ENV: z.enum(["development", "production", "test"]), - ANALYZE: z.string().optional(), - NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(), - - // Public - NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"), - NEXT_PUBLIC_MARBLE_API_URL: z.url(), - - // Server - DATABASE_URL: z.string().refine( - (url) => - url.startsWith("postgres://") || url.startsWith("postgresql://"), - "DATABASE_URL must be a postgres:// or postgresql:// URL", - ), - - BETTER_AUTH_SECRET: z.string(), - UPSTASH_REDIS_REST_URL: z.url(), - UPSTASH_REDIS_REST_TOKEN: z.string(), - MARBLE_WORKSPACE_KEY: z.string(), - FREESOUND_CLIENT_ID: z.string(), - FREESOUND_API_KEY: z.string(), -}); - -export type WebEnv = z.infer<typeof webEnvSchema>; - -export const webEnv = webEnvSchema.parse(process.env); diff --git a/apps/web/src/export/index.ts b/apps/web/src/export/index.ts index c5c522b3..1d91dbca 100644 --- a/apps/web/src/export/index.ts +++ b/apps/web/src/export/index.ts @@ -23,6 +23,7 @@ export interface ExportOptions { export interface ExportResult { success: boolean; buffer?: ArrayBuffer; + filePath?: string; error?: string; cancelled?: boolean; } diff --git a/apps/web/src/services/renderer/native-exporter.ts b/apps/web/src/services/renderer/native-exporter.ts new file mode 100644 index 00000000..f525f050 --- /dev/null +++ b/apps/web/src/services/renderer/native-exporter.ts @@ -0,0 +1,159 @@ +import EventEmitter from "eventemitter3"; + +import type { FrameRate } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { frameRateToFloat } from "@/fps/utils"; +import { getDesktopBridge } from "@/desktop/bridge"; +import type { ExportFormat, ExportQuality } from "@/export"; +import { CanvasRenderer } from "./canvas-renderer"; +import type { RootNode } from "./nodes/root-node"; + +type NativeExportParams = { + width: number; + height: number; + fps: FrameRate; + format: ExportFormat; + quality: ExportQuality; + shouldIncludeAudio?: boolean; + audioBuffer?: AudioBuffer; + fileName: string; +}; + +export type NativeExporterEvents = { + progress: [progress: number]; + complete: []; + error: [error: Error]; + cancelled: []; +}; + +export class NativeExporter extends EventEmitter<NativeExporterEvents> { + private renderer: CanvasRenderer; + private format: ExportFormat; + private quality: ExportQuality; + private shouldIncludeAudio: boolean; + private audioBuffer?: AudioBuffer; + private fileName: string; + + private isCancelled = false; + + constructor({ + width, + height, + fps, + format, + quality, + shouldIncludeAudio, + audioBuffer, + fileName, + }: NativeExportParams) { + super(); + this.renderer = new CanvasRenderer({ + width, + height, + fps, + }); + + this.format = format; + this.quality = quality; + this.shouldIncludeAudio = shouldIncludeAudio ?? false; + this.audioBuffer = audioBuffer; + this.fileName = fileName; + } + + cancel(): void { + this.isCancelled = true; + } + + private buildAudioPcm(): Float32Array | null { + if (!this.shouldIncludeAudio || !this.audioBuffer) return null; + + const { numberOfChannels, length } = this.audioBuffer; + const pcm = new Float32Array(length * numberOfChannels); + for (let channel = 0; channel < numberOfChannels; channel++) { + const channelData = this.audioBuffer.getChannelData(channel); + for (let i = 0; i < length; i++) { + pcm[i * numberOfChannels + channel] = channelData[i]; + } + } + return pcm; + } + + async export({ rootNode }: { rootNode: RootNode }): Promise<string | null> { + const bridge = getDesktopBridge(); + if (!bridge) { + throw new Error("Desktop bridge is not available"); + } + + const fps = this.renderer.fps; + const fpsFloat = frameRateToFloat(fps); + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + const frameCount = Math.floor(rootNode.duration / ticksPerFrame); + + const pcm = this.buildAudioPcm(); + + const begin = await bridge.exportBegin({ + width: this.renderer.width, + height: this.renderer.height, + fpsNumerator: fps.numerator, + fpsDenominator: fps.denominator, + format: this.format, + quality: this.quality, + hasAudio: pcm !== null, + sampleRate: this.audioBuffer?.sampleRate ?? 48000, + channels: this.audioBuffer?.numberOfChannels ?? 2, + defaultFileName: this.fileName, + }); + + if (!begin) { + this.emit("cancelled"); + return null; + } + + const { id, filePath } = begin; + + try { + if (pcm) { + await bridge.exportWriteAudio(id, pcm.buffer as ArrayBuffer); + } + + for (let i = 0; i < frameCount; i++) { + if (this.isCancelled) { + await bridge.exportCancel(id); + this.emit("cancelled"); + return null; + } + + const timeTicks = i * ticksPerFrame; + await this.renderer.render({ node: rootNode, time: timeTicks }); + + const frame = new VideoFrame(this.renderer.getOutputCanvas(), { + timestamp: Math.round((i * 1e6) / fpsFloat), + duration: Math.round(1e6 / fpsFloat), + }); + const size = frame.allocationSize({ format: "RGBA" }); + const buffer = new ArrayBuffer(size); + await frame.copyTo(buffer, { format: "RGBA" }); + frame.close(); + + await bridge.exportWriteFrame(id, buffer); + this.emit("progress", i / frameCount); + } + + if (this.isCancelled) { + await bridge.exportCancel(id); + this.emit("cancelled"); + return null; + } + + await bridge.exportFinish(id); + this.emit("progress", 1); + this.emit("complete"); + return filePath; + } catch (error) { + await bridge.exportCancel(id).catch(() => {}); + throw error; + } + } +} |
