Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion packages/cli/src/studio-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import type { CliContext } from './context.js';
import { AssetStore, generateTts, generateMusic } from '@html-video/core';
import { AssetStore, generateTts, generateMusic, formatExportDisplayName } from '@html-video/core';
import type { Project } from '@html-video/core';
import { extractUrls, fetchSource } from './fetch-source.js';
import { detectAll, findAgent, spawnAgent } from '@html-video/runtime';

Expand Down Expand Up @@ -68,6 +69,50 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise<
return json(res, 200, { projects: list });
}

// Flatten export history across all projects (newest first).
if (url.pathname === '/api/library' && m === 'GET') {
const projects = await ctx.orchestrator.list();
const items: Array<{
projectId: string;
projectName: string;
filename: string;
displayName: string;
createdAt: string;
}> = [];
for (const p of projects) {
const seen = new Set<string>();
for (const ex of p.exports ?? []) {
if (!ex.filename || seen.has(ex.filename)) continue;
if (!existsSync(ex.path)) continue;
seen.add(ex.filename);
items.push({
projectId: p.id,
projectName: p.name,
filename: ex.filename,
displayName: ex.displayName ?? formatExportDisplayName(ex.createdAt),
createdAt: ex.createdAt,
});
}
// Projects exported before the history field existed only have
// lastOutputMp4Path — surface that too so old exports aren't lost.
if (p.lastOutputMp4Path && existsSync(p.lastOutputMp4Path)) {
const fname = basename(p.lastOutputMp4Path);
if (!seen.has(fname)) {
const createdAt = p.updatedAt ?? new Date().toISOString();
items.push({
projectId: p.id,
projectName: p.name,
filename: fname,
displayName: formatExportDisplayName(createdAt),
createdAt,
});
}
}
}
items.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
return json(res, 200, { items });
}

// Create project
if (url.pathname === '/api/projects' && m === 'POST') {
const body = await readBody(req);
Expand Down Expand Up @@ -1265,6 +1310,30 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise<
const sub = previewServeMatch[2] ?? '/preview.html';
const project = await ctx.orchestrator.load(projId);

// Latest exported MP4 (shortcut) or a specific export from history.
if (sub === '/export.mp4' || sub === '/export.mp4/') {
const out = project.lastOutputMp4Path;
if (out && existsSync(out)) {
const fname = basename(out);
return serveExportMp4(out, fname, url, res, exportDownloadLabel(project, fname));
}
res.writeHead(404);
return res.end('No export yet');
}
const exportFileMatch = sub.match(/^\/exports\/([^/]+\.mp4)$/i);
if (exportFileMatch && exportFileMatch[1]) {
const filename = decodeURIComponent(exportFileMatch[1]);
const entry = (project.exports ?? []).find((e) => e.filename === filename);
if (entry && existsSync(entry.path)) {
return serveExportMp4(entry.path, filename, url, res, exportDownloadLabel(project, filename, entry));
}
if (project.lastOutputMp4Path && basename(project.lastOutputMp4Path) === filename && existsSync(project.lastOutputMp4Path)) {
return serveExportMp4(project.lastOutputMp4Path, filename, url, res, exportDownloadLabel(project, filename));
}
res.writeHead(404);
return res.end('Export not found');
}

// Phase C: serve an enhanced frame's preview MP4 (native Remotion frames
// have no HTML). Match the `.mp4` suffix BEFORE the plain HTML frame route.
const frameMp4Match = sub.match(/^\/frame\/([a-z0-9_-]+)\.mp4$/i);
Expand Down Expand Up @@ -1542,6 +1611,42 @@ function injectCompositionPlayer(html: string): string {
return out + player;
}

/** Human-friendly filename for the Content-Disposition header — prefers the
* export's displayName over the on-disk timestamp filename. */
function exportDownloadLabel(
project: Project,
storageFilename: string,
entry?: { displayName?: string; createdAt: string },
): string {
const resolved = entry ?? (project.exports ?? []).find((e) => e.filename === storageFilename);
const display = resolved?.displayName
?? formatExportDisplayName(resolved?.createdAt ?? project.updatedAt ?? new Date().toISOString());
const base = display.replace(/[/\\?%*:|"<>]/g, '-').trim().slice(0, 80) || 'video';
return base.endsWith('.mp4') ? base : `${base}.mp4`;
}

async function serveExportMp4(
filePath: string,
storageFilename: string,
url: URL,
res: ServerResponse,
downloadLabel?: string,
): Promise<void> {
const download = url.searchParams.get('download') === '1';
const buf = await readFile(filePath);
const headers: Record<string, string> = {
'content-type': 'video/mp4',
'cache-control': 'no-store, no-cache, must-revalidate',
pragma: 'no-cache',
};
if (download) {
const name = (downloadLabel ?? storageFilename).replace(/"/g, '');
headers['content-disposition'] = `attachment; filename="${name}"`;
}
res.writeHead(200, headers);
res.end(buf);
}

async function serveFile(filePath: string, res: ServerResponse): Promise<void> {
const ext = extname(filePath).toLowerCase();
const buf = await readFile(filePath);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type { ErrorCode } from './errors.js';
export { AssetStore } from './asset-store.js';
export type { AssetStoreOptions } from './asset-store.js';
export { EngineRegistry, TemplateRegistry, ProjectStore } from './registry.js';
export { ProjectOrchestrator } from './project.js';
export { ProjectOrchestrator, formatExportDisplayName } from './project.js';
export type {
CreateProjectInput,
ProjectOrchestratorDeps,
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,11 +922,25 @@ async function muxAudioWithFfmpeg(args: {
// Helpers
// ---------------------------------------------------------------------------

/** Default library label for an export — local date/time, minute precision. */
export function formatExportDisplayName(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

/** Append this export to the project's history (newest last, de-duped by path,
* capped so it doesn't grow unbounded). */
function recordExport(project: Project, outputPath: string): void {
const createdAt = new Date().toISOString();
const list = (project.exports ?? []).filter((e) => e.path !== outputPath);
list.push({ path: outputPath, filename: basename(outputPath), createdAt: new Date().toISOString() });
list.push({
path: outputPath,
filename: basename(outputPath),
createdAt,
displayName: formatExportDisplayName(createdAt),
});
// Keep the most recent 20.
project.exports = list.slice(-20);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ export interface Project {
lastOutputMp4Path?: string;
/** Export history — every MP4 exported for this project, newest last. Each
* export writes a uniquely-named file so older ones aren't overwritten. */
exports?: Array<{ path: string; createdAt: string; filename: string }>;
exports?: Array<{ path: string; createdAt: string; filename: string; displayName?: string }>;
/**
* v0.8: path to content-graph.json for multi-frame projects.
* Absent for single-frame fast-path projects.
Expand Down
Loading