Skills/Video Composition/Server-Side Rendering

Server-Side Rendering

Functions

getRenderInfo(html: string): Promise<RenderInfo>

Extract render metadata from HTML composition (Node.js safe)

Returns: RenderInfo

Editframe packages provide SSR-safe entry points for server-side rendering with Next.js, Remix, and other React frameworks. Import from @editframe/elements/server or @editframe/react/server to access types and components without triggering browser-specific code.

Problem: Browser APIs in SSR

The main package entries (@editframe/elements and @editframe/react) import browser-specific code at the module level:

  • Web Components (customElements.define())
  • DOM APIs (HTMLElement, document, window)
  • Canvas, WebGL, WebCodecs APIs

This code will crash if imported during server-side rendering because Node.js doesn't provide these APIs.

Solution: SSR-Safe Entry Points

Use the /server subpath exports that only include types and SSR-safe utilities.

@editframe/elements/server

Import types only and the getRenderInfo utility:

import type {
EFTimegroup,
EFMedia,
RenderToVideoOptions,
RenderProgress,
} from "@editframe/elements/server";
import { getRenderInfo } from "@editframe/elements/server";

What's Included

  • Types: TypeScript type definitions for all elements (zero runtime code)
  • getRenderInfo: Function to extract render metadata from HTML (Node.js safe)

What's NOT Included

  • Web Component class definitions
  • DOM manipulation utilities
  • Browser-specific rendering code
  • Canvas or WebGL APIs

getRenderInfo Example

// server.ts (Node.js safe)
import { getRenderInfo } from "@editframe/elements/server";
const html = `
<ef-timegroup mode="sequence" duration="30s">
<ef-video src="intro.mp4"></ef-video>
<ef-video src="outro.mp4"></ef-video>
</ef-timegroup>
`;
const info = await getRenderInfo(html);
console.log(info.durationMs); // 30000
console.log(info.width); // 1920
console.log(info.height); // 1080

@editframe/react/server

Import React components that render to HTML without triggering browser APIs:

import {
Timegroup,
Video,
Audio,
Image,
Text,
Captions,
Surface,
Waveform,
PanZoom,
} from "@editframe/react/server";

What's Included

  • All composition React components (render to <ef-*> tags)
  • Caption styling sub-components
  • All types from @editframe/elements/server

What's NOT Included

  • GUI components (Preview, Workbench, Controls, Timeline, etc.)
  • Hooks that depend on browser APIs (useTimingInfo, usePlayback, etc.)
  • Browser rendering utilities

Next.js Integration

App Router (React Server Components)

// app/video/[id]/page.tsx (Server Component)
import { Timegroup, Video, Audio } from "@editframe/react/server";
export default function VideoPage({ params }: { params: { id: string } }) {
return (
<Timegroup mode="sequence" className="w-[1920px] h-[1080px]">
<Video src={`/api/videos/${params.id}/intro.mp4`} className="size-full" />
<Audio src={`/api/videos/${params.id}/music.mp3`} volume={0.3} />
</Timegroup>
);
}

Pages Router (SSR)

Use dynamic imports with ssr: false for browser-only code:

// pages/editor.tsx
import dynamic from "next/dynamic";
// Safe: imports from /server
import { Timegroup, Video } from "@editframe/react/server";
// Unsafe: imports from main entry, requires client-side only
const Preview = dynamic(
() => import("@editframe/react").then((mod) => mod.Preview),
{ ssr: false }
);
const Workbench = dynamic(
() => import("@editframe/react").then((mod) => mod.Workbench),
{ ssr: false }
);
export default function EditorPage() {
return (
<div>
{/* This renders on server */}
<Timegroup mode="sequence" className="w-[1920px] h-[1080px]">
<Video src="/videos/intro.mp4" className="size-full" />
</Timegroup>
{/* This only renders on client */}
<Workbench rendering={false} />
</div>
);
}

Remix Integration

Use the /server entry for loaders and actions:

// routes/video.$id.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Timegroup, Video, Audio } from "@editframe/react/server";
export async function loader({ params }: LoaderFunctionArgs) {
const videoData = await fetchVideoData(params.id);
return json({ videoData });
}
export default function VideoRoute() {
const { videoData } = useLoaderData<typeof loader>();
return (
<Timegroup mode="sequence" className="w-[1920px] h-[1080px]">
<Video src={videoData.introUrl} className="size-full" />
<Audio src={videoData.musicUrl} volume={0.3} />
</Timegroup>
);
}

For client-only components, use ClientOnly:

import { ClientOnly } from "remix-utils/client-only";
export default function EditorRoute() {
return (
<ClientOnly>
{() => {
// Import happens only on client
const { Workbench } = require("@editframe/react");
return <Workbench rendering={false} />;
}}
</ClientOnly>
);
}

Pre-rendering Static HTML

Generate static HTML for compositions:

// build.ts (Node.js)
import { renderToString } from "react-dom/server";
import { Timegroup, Video, Text } from "@editframe/react/server";
import { writeFileSync } from "fs";
const composition = (
<Timegroup mode="fixed" duration="10s" className="w-[1920px] h-[1080px]">
<Video src="/assets/background.mp4" className="size-full" />
<Text className="absolute inset-0 flex items-center justify-center text-6xl">
Hello World
</Text>
</Timegroup>
);
const html = renderToString(composition);
writeFileSync("./dist/composition.html", html);

This HTML can then be hydrated on the client with the full browser-side code.

Type Imports

When you only need types (not runtime code), use type-only imports for maximum safety:

// Safe in any environment (no runtime code)
import type { EFTimegroup, RenderToVideoOptions } from "@editframe/elements/server";
function createRenderConfig(): RenderToVideoOptions {
return {
fps: 30,
codec: "h264",
scale: 1,
};
}

Common Patterns

Hydration After SSR

Server:

// server.tsx
import { renderToString } from "react-dom/server";
import { Timegroup, Video } from "@editframe/react/server";
const html = renderToString(
<Timegroup mode="sequence" className="w-[1920px] h-[1080px]">
<Video src="/video.mp4" className="size-full" />
</Timegroup>
);
// Send html to client

Client:

// client.tsx
import { hydrateRoot } from "react-dom/client";
import { Timegroup, Video } from "@editframe/react"; // Full browser version
hydrateRoot(
document.getElementById("root"),
<Timegroup mode="sequence" className="w-[1920px] h-[1080px]">
<Video src="/video.mp4" className="size-full" />
</Timegroup>
);

Conditional Imports

// utils.ts
export async function loadEditorComponents() {
if (typeof window === "undefined") {
throw new Error("Editor components require browser environment");
}
const { Workbench, Preview, Timeline } = await import("@editframe/react");
return { Workbench, Preview, Timeline };
}

Troubleshooting

Error: customElements is not defined

Problem: Importing from main entry point during SSR.

Solution: Use /server entry point:

// ❌ Wrong
import { Timegroup } from "@editframe/react";
// ✅ Correct
import { Timegroup } from "@editframe/react/server";

Error: HTMLElement is not defined

Problem: Web Component class definition loaded during SSR.

Solution: Use dynamic import with ssr: false or conditional import.

Error: document is not defined

Problem: Browser-specific code running on server.

Solution: Check imports and ensure all browser code is client-only.

Package Structure

Both packages expose three entry points:

@editframe/elements

{
"exports": {
".": "./dist/index.js", // Browser: Full package (Web Components, DOM APIs)
"./server": "./dist/server.js", // SSR: Types + getRenderInfo only
"./node": "./dist/node.js" // Node: Same as /server (backward compat)
}
}

@editframe/react

{
"exports": {
".": "./dist/index.js", // Browser: Full package (all components + hooks)
"./server": "./dist/server.js", // SSR: Composition components only
"./r3f": "./dist/r3f/index.js" // Browser: React Three Fiber integration
}
}