返回 html-video
registry.ts
根目录 / packages / core / src / registry.ts
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
218 lines TYPESCRIPT