| 1 | /** |
| 2 | * Registries for engine adapters, templates, and projects. |
| 3 | * RFC-05: Storyboard removed; Project takes its place. |
| 4 | */ |
| 5 | |
| 6 | import { mkdir, readFile, readdir, writeFile, rm } from 'node:fs/promises'; |
| 7 | import { existsSync } from 'node:fs'; |
| 8 | import { join } from 'node:path'; |
| 9 | import { parse as parseYaml } from 'yaml'; |
| 10 | import type { |
| 11 | EngineAdapter, |
| 12 | EngineId, |
| 13 | Project, |
| 14 | TemplateMetadata, |
| 15 | } from './types/index.js'; |
| 16 | import { HtmlVideoError } from './errors.js'; |
| 17 | |
| 18 | // --------------------------------------------------------------------------- |
| 19 | // EngineRegistry |
| 20 | // --------------------------------------------------------------------------- |
| 21 | |
| 22 | export class EngineRegistry { |
| 23 | private adapters = new Map<EngineId, EngineAdapter>(); |
| 24 | |
| 25 | register(adapter: EngineAdapter): void { |
| 26 | this.adapters.set(adapter.id, adapter); |
| 27 | } |
| 28 | |
| 29 | get(id: EngineId): EngineAdapter { |
| 30 | const a = this.adapters.get(id); |
| 31 | if (!a) { |
| 32 | throw new HtmlVideoError( |
| 33 | 'engine-not-registered', |
| 34 | `Engine "${id}" is not registered. Did you forget to install @html-video/adapter-${id}?`, |
| 35 | ); |
| 36 | } |
| 37 | return a; |
| 38 | } |
| 39 | |
| 40 | list(): EngineAdapter[] { |
| 41 | return [...this.adapters.values()]; |
| 42 | } |
| 43 | |
| 44 | has(id: EngineId): boolean { |
| 45 | return this.adapters.has(id); |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // --------------------------------------------------------------------------- |
| 50 | // TemplateRegistry |
| 51 | // --------------------------------------------------------------------------- |
| 52 | |
| 53 | export class TemplateRegistry { |
| 54 | private templates = new Map<string, TemplateMetadata>(); |
| 55 | |
| 56 | async scan(rootDir: string): Promise<TemplateMetadata[]> { |
| 57 | if (!existsSync(rootDir)) return []; |
| 58 | const entries = await readdir(rootDir, { withFileTypes: true }); |
| 59 | const found: TemplateMetadata[] = []; |
| 60 | for (const entry of entries) { |
| 61 | if (!entry.isDirectory()) continue; |
| 62 | const dir = join(rootDir, entry.name); |
| 63 | const yamlPath = join(dir, 'template.html-video.yaml'); |
| 64 | if (!existsSync(yamlPath)) continue; |
| 65 | const raw = await readFile(yamlPath, 'utf8'); |
| 66 | const meta = parseYaml(raw) as TemplateMetadata; |
| 67 | meta.__dir = dir; |
| 68 | this.templates.set(meta.id, meta); |
| 69 | found.push(meta); |
| 70 | } |
| 71 | return found; |
| 72 | } |
| 73 | |
| 74 | get(id: string): TemplateMetadata { |
| 75 | const t = this.templates.get(id); |
| 76 | if (!t) { |
| 77 | throw new HtmlVideoError('template-not-found', `Template "${id}" not found`); |
| 78 | } |
| 79 | return t; |
| 80 | } |
| 81 | |
| 82 | has(id: string): boolean { |
| 83 | return this.templates.has(id); |
| 84 | } |
| 85 | |
| 86 | list(): TemplateMetadata[] { |
| 87 | return [...this.templates.values()]; |
| 88 | } |
| 89 | |
| 90 | search(opts: { |
| 91 | intent?: string; |
| 92 | aspect?: string; |
| 93 | licenseAllow?: string[]; |
| 94 | enginesAvailable?: EngineId[]; |
| 95 | top?: number; |
| 96 | }): { template: TemplateMetadata; score: number; reason: string }[] { |
| 97 | const top = opts.top ?? 5; |
| 98 | const intentLower = (opts.intent ?? '').toLowerCase(); |
| 99 | const intentTokens = intentLower.split(/\W+/).filter((s) => s.length > 2); |
| 100 | |
| 101 | const ranked: { template: TemplateMetadata; score: number; reason: string }[] = []; |
| 102 | |
| 103 | for (const t of this.templates.values()) { |
| 104 | const reasonParts: string[] = []; |
| 105 | let score = 0; |
| 106 | |
| 107 | const haystack = [ |
| 108 | ...t.tags, |
| 109 | ...t.best_for, |
| 110 | t.name, |
| 111 | t.description, |
| 112 | t.category, |
| 113 | t.subcategory ?? '', |
| 114 | ] |
| 115 | .join(' ') |
| 116 | .toLowerCase(); |
| 117 | const matched = intentTokens.filter((tok) => haystack.includes(tok)); |
| 118 | if (matched.length > 0) { |
| 119 | score += matched.length * 0.2; |
| 120 | reasonParts.push(`matched ${matched.length} intent tokens`); |
| 121 | } |
| 122 | |
| 123 | if (opts.aspect) { |
| 124 | if (t.output.resolution.supported_aspects.includes(opts.aspect)) { |
| 125 | score += 0.15; |
| 126 | reasonParts.push(`aspect ${opts.aspect} supported`); |
| 127 | } else { |
| 128 | score -= 0.1; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | if (opts.licenseAllow && !opts.licenseAllow.includes(t.license.spdx)) { |
| 133 | continue; |
| 134 | } |
| 135 | reasonParts.push(`license ${t.license.spdx} ok`); |
| 136 | |
| 137 | if (opts.enginesAvailable && !opts.enginesAvailable.includes(t.engine)) { |
| 138 | continue; |
| 139 | } |
| 140 | |
| 141 | score = Math.max(0, Math.min(1, score)); |
| 142 | |
| 143 | ranked.push({ |
| 144 | template: t, |
| 145 | score, |
| 146 | reason: reasonParts.join('; '), |
| 147 | }); |
| 148 | } |
| 149 | |
| 150 | ranked.sort((a, b) => b.score - a.score); |
| 151 | return ranked.slice(0, top); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // --------------------------------------------------------------------------- |
| 156 | // ProjectStore — JSON-on-disk persistence |
| 157 | // --------------------------------------------------------------------------- |
| 158 | |
| 159 | export class ProjectStore { |
| 160 | constructor(private projectRoot: string) {} |
| 161 | |
| 162 | private dir(): string { |
| 163 | return join(this.projectRoot, '.html-video', 'projects'); |
| 164 | } |
| 165 | |
| 166 | private projectDir(id: string): string { |
| 167 | return join(this.dir(), id); |
| 168 | } |
| 169 | |
| 170 | private path(id: string): string { |
| 171 | return join(this.projectDir(id), 'project.json'); |
| 172 | } |
| 173 | |
| 174 | /** Ensure project directory exists; returns its absolute path. */ |
| 175 | async ensureDir(id: string): Promise<string> { |
| 176 | const dir = this.projectDir(id); |
| 177 | await mkdir(join(dir, 'assets'), { recursive: true }); |
| 178 | return dir; |
| 179 | } |
| 180 | |
| 181 | async save(project: Project): Promise<void> { |
| 182 | await this.ensureDir(project.id); |
| 183 | project.updatedAt = new Date().toISOString(); |
| 184 | await writeFile(this.path(project.id), JSON.stringify(project, null, 2), 'utf8'); |
| 185 | } |
| 186 | |
| 187 | async load(id: string): Promise<Project> { |
| 188 | const p = this.path(id); |
| 189 | if (!existsSync(p)) { |
| 190 | throw new HtmlVideoError('project-not-found', `Project ${id} not found`); |
| 191 | } |
| 192 | return JSON.parse(await readFile(p, 'utf8')) as Project; |
| 193 | } |
| 194 | |
| 195 | async list(): Promise<Project[]> { |
| 196 | const d = this.dir(); |
| 197 | if (!existsSync(d)) return []; |
| 198 | const ids = await readdir(d); |
| 199 | const out: Project[] = []; |
| 200 | for (const id of ids) { |
| 201 | try { |
| 202 | out.push(await this.load(id)); |
| 203 | } catch { |
| 204 | // skip corrupt |
| 205 | } |
| 206 | } |
| 207 | out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); |
| 208 | return out; |
| 209 | } |
| 210 | |
| 211 | async remove(id: string): Promise<void> { |
| 212 | const dir = this.projectDir(id); |
| 213 | if (existsSync(dir)) { |
| 214 | await rm(dir, { recursive: true, force: true }); |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 |