返回 html-video
asset-store.ts
根目录 / packages / core / src / asset-store.ts
1 /**
2 * Content-addressed asset store, scoped per project.
3 * RFC-05 §文件存储.
4 */
5
6 import { createHash } from 'node:crypto';
7 import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
8 import { existsSync } from 'node:fs';
9 import { extname, join } from 'node:path';
10 import type { Asset, AssetType } from './types/index.js';
11 import { HtmlVideoError } from './errors.js';
12
13 export interface AssetStoreOptions {
14 projectRoot: string;
15 }
16
17 export class AssetStore {
18 private readonly projectsDir: string;
19
20 constructor(opts: AssetStoreOptions) {
21 this.projectsDir = join(opts.projectRoot, '.html-video', 'projects');
22 }
23
24 private projectDir(projectId: string): string {
25 return join(this.projectsDir, projectId);
26 }
27
28 private assetsDir(projectId: string): string {
29 return join(this.projectDir(projectId), 'assets');
30 }
31
32 static async computeId(filePath: string): Promise<string> {
33 const buf = await readFile(filePath);
34 return createHash('sha1').update(buf).digest('hex');
35 }
36
37 static computeInlineId(content: string): string {
38 return createHash('sha1').update(content).digest('hex');
39 }
40
41 static guessMime(filePath: string): { mime: string; type: AssetType } {
42 const ext = extname(filePath).toLowerCase();
43 const map: Record<string, { mime: string; type: AssetType }> = {
44 '.png': { mime: 'image/png', type: 'image' },
45 '.jpg': { mime: 'image/jpeg', type: 'image' },
46 '.jpeg': { mime: 'image/jpeg', type: 'image' },
47 '.webp': { mime: 'image/webp', type: 'image' },
48 '.gif': { mime: 'image/gif', type: 'image' },
49 '.svg': { mime: 'image/svg+xml', type: 'image' },
50 '.mp3': { mime: 'audio/mpeg', type: 'audio' },
51 '.wav': { mime: 'audio/wav', type: 'audio' },
52 '.aac': { mime: 'audio/aac', type: 'audio' },
53 '.m4a': { mime: 'audio/mp4', type: 'audio' },
54 '.mp4': { mime: 'video/mp4', type: 'video' },
55 '.webm': { mime: 'video/webm', type: 'video' },
56 '.mov': { mime: 'video/quicktime', type: 'video' },
57 '.csv': { mime: 'text/csv', type: 'data' },
58 '.json': { mime: 'application/json', type: 'data' },
59 '.tsv': { mime: 'text/tab-separated-values', type: 'data' },
60 '.txt': { mime: 'text/plain', type: 'text' },
61 '.md': { mime: 'text/markdown', type: 'text' },
62 };
63 return map[ext] ?? { mime: 'application/octet-stream', type: 'reference-link' };
64 }
65
66 async addFileAsset(
67 projectId: string,
68 sourcePath: string,
69 userTags: string[] = [],
70 userCaption?: string,
71 ): Promise<Asset> {
72 if (!existsSync(sourcePath)) {
73 throw new HtmlVideoError('asset-not-found', `Source file not found: ${sourcePath}`);
74 }
75 const id = await AssetStore.computeId(sourcePath);
76 const { mime, type } = AssetStore.guessMime(sourcePath);
77 const ext = extname(sourcePath);
78 const dir = this.assetsDir(projectId);
79 await mkdir(dir, { recursive: true });
80 const destPath = join(dir, `${id}${ext}`);
81 if (!existsSync(destPath)) {
82 await copyFile(sourcePath, destPath);
83 }
84 const st = await stat(destPath);
85 const filename = sourcePath.split('/').pop() ?? sourcePath;
86 return {
87 id,
88 type,
89 path: destPath,
90 metadata: {
91 filename,
92 mimeType: mime,
93 sizeBytes: st.size,
94 ...(userCaption !== undefined && { userCaption }),
95 },
96 userTags,
97 };
98 }
99
100 async addInlineAsset(
101 projectId: string,
102 content: string,
103 type: 'text' | 'data',
104 userTags: string[] = [],
105 userCaption?: string,
106 ): Promise<Asset> {
107 const id = AssetStore.computeInlineId(content);
108 const dir = this.assetsDir(projectId);
109 await mkdir(dir, { recursive: true });
110 const ext = type === 'data' ? '.json' : '.txt';
111 const destPath = join(dir, `${id}${ext}`);
112 if (!existsSync(destPath)) {
113 await writeFile(destPath, content, 'utf8');
114 }
115 return {
116 id,
117 type,
118 path: destPath,
119 content,
120 metadata: {
121 filename: `inline${ext}`,
122 mimeType: type === 'data' ? 'application/json' : 'text/plain',
123 sizeBytes: Buffer.byteLength(content, 'utf8'),
124 ...(userCaption !== undefined && { userCaption }),
125 },
126 userTags,
127 };
128 }
129
130 /**
131 * Store raw bytes (e.g. an MP3 returned by a generation API) as a
132 * content-addressed asset. The id is the sha1 of the bytes, so identical
133 * payloads dedupe; `ext` drives the mime/type via {@link guessMime}.
134 */
135 async addBufferAsset(
136 projectId: string,
137 bytes: Buffer,
138 ext: string,
139 userTags: string[] = [],
140 userCaption?: string,
141 ): Promise<Asset> {
142 if (bytes.length === 0) {
143 throw new HtmlVideoError('invalid-input', 'addBufferAsset: empty buffer');
144 }
145 const normExt = ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`;
146 const id = createHash('sha1').update(bytes).digest('hex');
147 const { mime, type } = AssetStore.guessMime(`x${normExt}`);
148 const dir = this.assetsDir(projectId);
149 await mkdir(dir, { recursive: true });
150 const destPath = join(dir, `${id}${normExt}`);
151 if (!existsSync(destPath)) {
152 await writeFile(destPath, bytes);
153 }
154 return {
155 id,
156 type,
157 path: destPath,
158 metadata: {
159 filename: `${id}${normExt}`,
160 mimeType: mime,
161 sizeBytes: bytes.length,
162 ...(userCaption !== undefined && { userCaption }),
163 },
164 userTags,
165 };
166 }
167
168 resolvePath(asset: Asset): string {
169 if (asset.path) return asset.path;
170 throw new HtmlVideoError('asset-not-found', `Asset ${asset.id} has no path`);
171 }
172 }
173
173 lines TYPESCRIPT