Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,27 +1,42 @@
import * as fs from 'fs-extra';
import path from 'path';
import type { PackageManager } from './utils/packageManager';

const TEMPLATES_ROOT = path.join(__dirname, '../templates');

export async function applyTemplate(
destination: string,
templateName: string,
projectName: string
): Promise<void> {
const templatePath = path.join(TEMPLATES_ROOT, templateName);
export type TemplateProject = {
projectName: string;
// Relative from template root, defaults to root dir
dir?: string;
// Relative from root from root of the project
dirsToRemove?: string[];
};

export type Template = {
templateId: string;
projects: TemplateProject[];
usageInstructions: (directoryName: string, packageManage: PackageManager) => string;
};

export async function applyTemplate(template: Template, destination: string): Promise<void> {
const templatePath = path.join(TEMPLATES_ROOT, template.templateId);
await fs.copy(templatePath, destination);

await fs.remove(path.join(destination, 'node_modules'));
await fs.remove(path.join(destination, 'dist'));
for (const project of template.projects) {
const projectDir = path.join(destination, project.dir ?? '.');
for (const dirToRemove of project.dirsToRemove ?? []) {
await fs.remove(path.join(projectDir, dirToRemove));
}

const packageJsonPath = path.join(destination, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const transformedPackageJson = transformPackageJson(packageJson, projectName);
await fs.writeFile(
packageJsonPath,
JSON.stringify(transformedPackageJson, null, 2) + '\n',
'utf8'
);
const packageJsonPath = path.join(projectDir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const transformedPackageJson = transformPackageJson(packageJson, project.projectName);
await fs.writeFile(
packageJsonPath,
JSON.stringify(transformedPackageJson, null, 2) + '\n',
'utf8'
);
}
}

export function transformPackageJson(packageJson: any, projectName: string): any {
Expand Down
10 changes: 0 additions & 10 deletions ts/create-smelter-app/src/createNodeProject.ts

This file was deleted.

108 changes: 27 additions & 81 deletions ts/create-smelter-app/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,112 +4,58 @@ import path from 'path';
import type { PackageManager } from './utils/packageManager';
import { spawn } from './utils/spawn';
import chalk from 'chalk';
import type { Template } from './applyTemplate';
import type { TemplateOption } from './templates';
import {
NodeExpressZustandTemplate,
NodeMinimalTemplate,
NodeNextWebRTCTemplate,
OfflineNodeMinimalTemplate,
OfflineNodeShowcaseTemplate,
} from './templates';

export type ProjectOptions = {
projectName: string;
directory: string;
packageManager: PackageManager;
runtime: BrowserOptions | NodeOptions;
template: Template;
};

type BrowserOptions = {
type: 'browser';
embeddedWasm: boolean;
templateName: 'vite' | 'next';
};

type NodeOptions = {
type: 'node';
templateName: string;
};

type Runtime = 'node' | 'browser';

const packageManagers: Choice<PackageManager>[] = [
{ value: 'npm', title: 'npm' },
{ value: 'yarn', title: 'yarn' },
{ value: 'pnpm', title: 'pnpm' },
];

const templateOptions: TemplateOption[] = [
NodeMinimalTemplate,
NodeExpressZustandTemplate,
NodeNextWebRTCTemplate,
OfflineNodeMinimalTemplate,
OfflineNodeShowcaseTemplate,
];

export async function resolveOptions(): Promise<ProjectOptions> {
const projectName = await textPrompt('Project name: ', 'smelter-app');
await checkFFmpeg();
// TODO: replace
// const runtime = await selectPrompt('Select environment:', [
// { value: 'node', title: 'Node.js' },
// { value: 'browser', title: 'Browser' },
// ] as const);
const runtime: Runtime = 'node' as any;

const packageManager = await resolvePackageManager();

let runtimeOptions: ProjectOptions['runtime'];
if (runtime === 'browser') {
runtimeOptions = await resolveBrowserOptions();
} else if (runtime === 'node') {
runtimeOptions = await resolveNodeOptions();
} else {
throw new Error('Unknown runtime');
}
const template = await selectPrompt(
'Select project template: ',
templateOptions.map(option => ({
title: option.title,
description: option.description,
value: option.resolveTemplate(projectName),
}))
);

return {
runtime: runtimeOptions,
packageManager,
projectName,
template,
directory: path.join(process.cwd(), projectName),
};
}

export async function resolveBrowserOptions(): Promise<BrowserOptions> {
const usageType = await selectPrompt('Where do you want to run the Smelter server?', [
{ value: 'external', title: 'Run as an external instance and communicate over the network.' },
{ value: 'wasm', title: 'Embed Smelter in the browser and render using WebGL.' },
]);
const templateName = await selectPrompt('Select project template:', [
{ value: 'vite', title: 'Vite + React' },
{ value: 'next', title: 'Next.js' },
] as const);

return {
type: 'browser',
embeddedWasm: usageType === 'wasm',
templateName,
};
}

export async function resolveNodeOptions(): Promise<NodeOptions> {
const templateName = await selectPrompt('Select project template: ', [
{
title: 'Minimal example',
description:
'A Node.js application that streams a simple static layout to a local RTMP server.',
value: 'node-minimal',
},
{
title: 'Express.js + Zustand',
description:
'A Node.js application that streams composed video to an RTMP server. An HTTP API controls the stream layout, enables dynamic layout changes and adding MP4 files.',
value: 'node-express-zustand',
},
{
title: 'Generate simple MP4 file',
description:
'A Node.js application that generates an MP4 file, rendering a single, simple static layout.',
value: 'node-offline-minimal',
},
{
title: 'Converting and combining MP4 files',
description:
'A Node.js application that generates an MP4 file by combining and composing multiple source MP4 files.',
value: 'node-offline-showcase',
},
] as const);
return {
type: 'node',
templateName,
};
}

export async function checkFFmpeg(): Promise<void> {
try {
await spawn('ffplay', ['-version'], { stdio: 'pipe' });
Expand Down
31 changes: 19 additions & 12 deletions ts/create-smelter-app/src/run.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import chalk from 'chalk';
import type { ProjectOptions } from './options';
import { resolveOptions } from './options';
import { createNodeProject } from './createNodeProject';
import { ensureProjectDir } from './utils/workingdir';
import { applyTemplate } from './applyTemplate';
import { runPackageManagerInstall } from './utils/packageManager';
import path from 'path';

export default async function () {
export default async function run() {
const options = await resolveOptions();
if (options.runtime.type === 'node') {
console.log('Generating Node.js Smelter project');
await createNodeProject(options);
} else {
throw new Error('Unknown project type.');
}
console.log(`Generating project in ${options.directory}`);
await createNodeProject(options);

console.log();
console.log(chalk.green('Project created successfully.'));
console.log();
console.log(`To get started run:`);
console.log(
chalk.bold(
`$ cd ${options.projectName} && ${options.packageManager} run build && node ./dist/index.js`
)
options.template.usageInstructions(path.basename(options.directory), options.packageManager)
);
}

async function createNodeProject(options: ProjectOptions) {
await ensureProjectDir(options.directory);
await applyTemplate(options.template, options.directory);
for (const project of options.template.projects) {
const projectDir = path.join(options.directory, project.dir ?? '.');
await runPackageManagerInstall(options.packageManager, projectDir);
}
}
113 changes: 113 additions & 0 deletions ts/create-smelter-app/src/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { Template } from './applyTemplate';
import type { PackageManager } from './utils/packageManager';
import chalk from 'chalk';

export interface TemplateOption {
title: string;
description: string;

resolveTemplate: (projectName: string) => Template;
}

function defaultNodeInstructions(directoryName: string, packageManager: PackageManager): string {
return (
'To get started run:\n' +
`$ cd ${directoryName} && ${packageManager} run build && node ./dist/index.js`
);
}

export const NodeMinimalTemplate: TemplateOption = {
title: 'Minimal example',
description: 'A Node.js application that streams a simple static layout to a local RTMP server.',
resolveTemplate: projectName => ({
templateId: 'node-minimal',
projects: [
{
projectName,
dirsToRemove: ['dist', 'node_modules'],
},
],
usageInstructions: defaultNodeInstructions,
}),
};

export const NodeExpressZustandTemplate: TemplateOption = {
title: 'Express.js + Zustand',
description:
'A Node.js application that streams composed video to an RTMP server. An HTTP API controls the stream layout, enables dynamic layout changes and adding MP4 files.',
resolveTemplate: projectName => ({
templateId: 'node-express-zustand',
projects: [
{
projectName,
dirsToRemove: ['dist', 'node_modules'],
},
],
usageInstructions: defaultNodeInstructions,
}),
};

export const OfflineNodeMinimalTemplate: TemplateOption = {
title: 'Generate simple MP4 file',
description:
'A Node.js application that generates an MP4 file, rendering a single, simple static layout.',
resolveTemplate: projectName => ({
templateId: 'node-offline-minimal',
projects: [
{
projectName,
dirsToRemove: ['dist', 'node_modules'],
},
],
usageInstructions: defaultNodeInstructions,
}),
};

export const OfflineNodeShowcaseTemplate: TemplateOption = {
title: 'Converting and combining MP4 files',
description:
'A Node.js application that generates an MP4 file by combining and composing multiple source MP4 files.',
resolveTemplate: projectName => ({
templateId: 'node-offline-showcase',
projects: [
{
projectName,
dirsToRemove: ['dist', 'node_modules'],
},
],
usageInstructions: defaultNodeInstructions,
}),
};

export const NodeNextWebRTCTemplate: TemplateOption = {
title: 'Streaming between Smelter and Next.js app via WebRTC',
description:
'A Next.js application that streams camera or screen share to the Smelter instance over WHIP. Smelter modifies the stream and broadcasts it over WHEP.',
resolveTemplate: projectName => ({
templateId: 'node-next-webrtc',
projects: [
{
projectName,
dir: 'server',
dirsToRemove: ['dist', 'node_modules'],
},
{
projectName,
dir: 'client',
dirsToRemove: [
'.next',
'next-env.d.ts',
'node_modules',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
],
},
],
usageInstructions: (directoryName, packageManager) =>
'To get started:\n\n' +
'1. Start the Node.js server:\n' +
` $ ${chalk.bold(`cd ${directoryName}/server && ${packageManager} run build && node ./dist/index.js`)}\n\n` +
'2. In a new terminal, start the Next.js app:\n' +
` $ ${chalk.bold(`cd ${directoryName}/client && ${packageManager} run dev`)}`,
}),
};
Loading