@clypra/runtime 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Golden Test Framework
3
+ *
4
+ * Renders effects and compares output frames against reference "golden" images
5
+ * to detect visual regressions. Uses pixel-perfect comparison with tolerance.
6
+ *
7
+ * Phase 6 Week 10 - Publishing Pipeline #2
8
+ */
9
+
10
+ export interface GoldenTestCase {
11
+ id: string;
12
+ effectId: string;
13
+ name: string;
14
+ description?: string;
15
+ parameters: Record<string, any>;
16
+ inputs: {
17
+ source: string; // Path to test video/image
18
+ [key: string]: any;
19
+ };
20
+ frames: number[]; // Frame numbers to test
21
+ goldenImagePath: string; // Path to reference image
22
+ tolerance: number; // Similarity threshold (0.0-1.0, default 0.999)
23
+ }
24
+
25
+ export interface GoldenTestResult {
26
+ testId: string;
27
+ effectId: string;
28
+ passed: boolean;
29
+ frames: FrameComparisonResult[];
30
+ summary: {
31
+ totalFrames: number;
32
+ passedFrames: number;
33
+ failedFrames: number;
34
+ averageSimilarity: number;
35
+ minSimilarity: number;
36
+ maxDifference: number;
37
+ };
38
+ executedAt: string;
39
+ }
40
+
41
+ export interface FrameComparisonResult {
42
+ frameNumber: number;
43
+ passed: boolean;
44
+ similarity: number; // 0.0-1.0
45
+ pixelDifference: number; // Number of different pixels
46
+ totalPixels: number;
47
+ differencePercentage: number;
48
+ diffImagePath?: string; // Path to generated diff image
49
+ }
50
+
51
+ /**
52
+ * Image comparison utilities
53
+ */
54
+ export class ImageComparator {
55
+ /**
56
+ * Compare two images pixel by pixel
57
+ */
58
+ static compareImages(
59
+ imageA: ImageData,
60
+ imageB: ImageData,
61
+ tolerance: number = 0.999,
62
+ ): {
63
+ similarity: number;
64
+ pixelDifference: number;
65
+ diffImage: ImageData;
66
+ } {
67
+ if (imageA.width !== imageB.width || imageA.height !== imageB.height) {
68
+ throw new Error("Images must have the same dimensions");
69
+ }
70
+
71
+ const totalPixels = imageA.width * imageA.height;
72
+ let matchingPixels = 0;
73
+ let pixelDifference = 0;
74
+
75
+ // Create diff image (red channel shows differences)
76
+ const diffImage = new ImageData(imageA.width, imageA.height);
77
+
78
+ for (let i = 0; i < imageA.data.length; i += 4) {
79
+ const rA = imageA.data[i];
80
+ const gA = imageA.data[i + 1];
81
+ const bA = imageA.data[i + 2];
82
+ const aA = imageA.data[i + 3];
83
+
84
+ const rB = imageB.data[i];
85
+ const gB = imageB.data[i + 1];
86
+ const bB = imageB.data[i + 2];
87
+ const aB = imageB.data[i + 3];
88
+
89
+ // Calculate color distance
90
+ const dr = rA - rB;
91
+ const dg = gA - gB;
92
+ const db = bA - bB;
93
+ const da = aA - aB;
94
+
95
+ const distance = Math.sqrt(dr * dr + dg * dg + db * db + da * da);
96
+ const maxDistance = Math.sqrt(255 * 255 * 4); // Max possible distance
97
+
98
+ const pixelSimilarity = 1.0 - distance / maxDistance;
99
+
100
+ if (pixelSimilarity >= 1.0 - (1.0 - tolerance)) {
101
+ matchingPixels++;
102
+
103
+ // Matching pixel - show original
104
+ diffImage.data[i] = rA;
105
+ diffImage.data[i + 1] = gA;
106
+ diffImage.data[i + 2] = bA;
107
+ diffImage.data[i + 3] = 255;
108
+ } else {
109
+ pixelDifference++;
110
+
111
+ // Different pixel - highlight in red
112
+ diffImage.data[i] = 255;
113
+ diffImage.data[i + 1] = 0;
114
+ diffImage.data[i + 2] = 0;
115
+ diffImage.data[i + 3] = 255;
116
+ }
117
+ }
118
+
119
+ const similarity = matchingPixels / totalPixels;
120
+
121
+ return {
122
+ similarity,
123
+ pixelDifference,
124
+ diffImage,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Calculate PSNR (Peak Signal-to-Noise Ratio)
130
+ */
131
+ static calculatePSNR(imageA: ImageData, imageB: ImageData): number {
132
+ if (imageA.width !== imageB.width || imageA.height !== imageB.height) {
133
+ throw new Error("Images must have the same dimensions");
134
+ }
135
+
136
+ let mse = 0;
137
+ const totalPixels = imageA.width * imageA.height * 4; // RGBA
138
+
139
+ for (let i = 0; i < imageA.data.length; i++) {
140
+ const diff = imageA.data[i] - imageB.data[i];
141
+ mse += diff * diff;
142
+ }
143
+
144
+ mse /= totalPixels;
145
+
146
+ if (mse === 0) {
147
+ return Infinity; // Perfect match
148
+ }
149
+
150
+ const maxValue = 255;
151
+ const psnr = 10 * Math.log10((maxValue * maxValue) / mse);
152
+
153
+ return psnr;
154
+ }
155
+
156
+ /**
157
+ * Calculate SSIM (Structural Similarity Index)
158
+ * Simplified version - full SSIM is more complex
159
+ */
160
+ static calculateSSIM(imageA: ImageData, imageB: ImageData): number {
161
+ // Simplified SSIM using luminance only
162
+ // Full SSIM would need windowing and structure comparison
163
+
164
+ if (imageA.width !== imageB.width || imageA.height !== imageB.height) {
165
+ throw new Error("Images must have the same dimensions");
166
+ }
167
+
168
+ const totalPixels = imageA.width * imageA.height;
169
+
170
+ // Calculate means
171
+ let meanA = 0;
172
+ let meanB = 0;
173
+
174
+ for (let i = 0; i < imageA.data.length; i += 4) {
175
+ // Use luminance (Rec. 709)
176
+ const lumA = 0.2126 * imageA.data[i] + 0.7152 * imageA.data[i + 1] + 0.0722 * imageA.data[i + 2];
177
+ const lumB = 0.2126 * imageB.data[i] + 0.7152 * imageB.data[i + 1] + 0.0722 * imageB.data[i + 2];
178
+
179
+ meanA += lumA;
180
+ meanB += lumB;
181
+ }
182
+
183
+ meanA /= totalPixels;
184
+ meanB /= totalPixels;
185
+
186
+ // Calculate variances and covariance
187
+ let varA = 0;
188
+ let varB = 0;
189
+ let covar = 0;
190
+
191
+ for (let i = 0; i < imageA.data.length; i += 4) {
192
+ const lumA = 0.2126 * imageA.data[i] + 0.7152 * imageA.data[i + 1] + 0.0722 * imageA.data[i + 2];
193
+ const lumB = 0.2126 * imageB.data[i] + 0.7152 * imageB.data[i + 1] + 0.0722 * imageB.data[i + 2];
194
+
195
+ const diffA = lumA - meanA;
196
+ const diffB = lumB - meanB;
197
+
198
+ varA += diffA * diffA;
199
+ varB += diffB * diffB;
200
+ covar += diffA * diffB;
201
+ }
202
+
203
+ varA /= totalPixels;
204
+ varB /= totalPixels;
205
+ covar /= totalPixels;
206
+
207
+ // SSIM constants
208
+ const c1 = (0.01 * 255) ** 2;
209
+ const c2 = (0.03 * 255) ** 2;
210
+
211
+ // SSIM formula
212
+ const ssim = ((2 * meanA * meanB + c1) * (2 * covar + c2)) / ((meanA * meanA + meanB * meanB + c1) * (varA + varB + c2));
213
+
214
+ return ssim;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Golden Test Runner
220
+ */
221
+ export class GoldenTestRunner {
222
+ private testCases: Map<string, GoldenTestCase> = new Map();
223
+
224
+ /**
225
+ * Register a golden test case
226
+ */
227
+ registerTest(testCase: GoldenTestCase): void {
228
+ this.testCases.set(testCase.id, testCase);
229
+ }
230
+
231
+ /**
232
+ * Run a single golden test
233
+ */
234
+ async runTest(testId: string, renderer: EffectRenderer): Promise<GoldenTestResult> {
235
+ const testCase = this.testCases.get(testId);
236
+ if (!testCase) {
237
+ throw new Error(`Test case not found: ${testId}`);
238
+ }
239
+
240
+ const frameResults: FrameComparisonResult[] = [];
241
+ let totalSimilarity = 0;
242
+ let minSimilarity = 1.0;
243
+ let maxDifference = 0;
244
+
245
+ for (const frameNumber of testCase.frames) {
246
+ // Render the effect at this frame
247
+ const renderedImage = await renderer.renderFrame(testCase.effectId, testCase.parameters, testCase.inputs, frameNumber);
248
+
249
+ // Load golden image
250
+ const goldenImage = await this.loadGoldenImage(testCase.goldenImagePath, frameNumber);
251
+
252
+ // Compare images
253
+ const comparison = ImageComparator.compareImages(renderedImage, goldenImage, testCase.tolerance);
254
+
255
+ const passed = comparison.similarity >= testCase.tolerance;
256
+ const differencePercentage = (comparison.pixelDifference / (renderedImage.width * renderedImage.height)) * 100;
257
+
258
+ frameResults.push({
259
+ frameNumber,
260
+ passed,
261
+ similarity: comparison.similarity,
262
+ pixelDifference: comparison.pixelDifference,
263
+ totalPixels: renderedImage.width * renderedImage.height,
264
+ differencePercentage,
265
+ diffImagePath: passed ? undefined : await this.saveDiffImage(testId, frameNumber, comparison.diffImage),
266
+ });
267
+
268
+ totalSimilarity += comparison.similarity;
269
+ minSimilarity = Math.min(minSimilarity, comparison.similarity);
270
+ maxDifference = Math.max(maxDifference, differencePercentage);
271
+ }
272
+
273
+ const passedFrames = frameResults.filter((r) => r.passed).length;
274
+ const failedFrames = frameResults.length - passedFrames;
275
+ const averageSimilarity = totalSimilarity / frameResults.length;
276
+
277
+ return {
278
+ testId,
279
+ effectId: testCase.effectId,
280
+ passed: failedFrames === 0,
281
+ frames: frameResults,
282
+ summary: {
283
+ totalFrames: frameResults.length,
284
+ passedFrames,
285
+ failedFrames,
286
+ averageSimilarity,
287
+ minSimilarity,
288
+ maxDifference,
289
+ },
290
+ executedAt: new Date().toISOString(),
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Run all registered golden tests
296
+ */
297
+ async runAllTests(renderer: EffectRenderer): Promise<GoldenTestResult[]> {
298
+ const results: GoldenTestResult[] = [];
299
+
300
+ for (const testId of this.testCases.keys()) {
301
+ const result = await this.runTest(testId, renderer);
302
+ results.push(result);
303
+ }
304
+
305
+ return results;
306
+ }
307
+
308
+ /**
309
+ * Generate a test report
310
+ */
311
+ generateReport(results: GoldenTestResult[]): string {
312
+ const totalTests = results.length;
313
+ const passedTests = results.filter((r) => r.passed).length;
314
+ const failedTests = totalTests - passedTests;
315
+
316
+ let report = "# Golden Test Report\n\n";
317
+ report += `**Date:** ${new Date().toISOString()}\n\n`;
318
+ report += `## Summary\n\n`;
319
+ report += `- **Total Tests:** ${totalTests}\n`;
320
+ report += `- **Passed:** ${passedTests} ✅\n`;
321
+ report += `- **Failed:** ${failedTests} ${failedTests > 0 ? "❌" : ""}\n`;
322
+ report += `- **Success Rate:** ${((passedTests / totalTests) * 100).toFixed(1)}%\n\n`;
323
+
324
+ report += `## Test Results\n\n`;
325
+
326
+ for (const result of results) {
327
+ const status = result.passed ? "✅ PASS" : "❌ FAIL";
328
+ report += `### ${status} ${result.effectId}\n\n`;
329
+ report += `- **Test ID:** ${result.testId}\n`;
330
+ report += `- **Frames Tested:** ${result.summary.totalFrames}\n`;
331
+ report += `- **Passed Frames:** ${result.summary.passedFrames}\n`;
332
+ report += `- **Failed Frames:** ${result.summary.failedFrames}\n`;
333
+ report += `- **Average Similarity:** ${(result.summary.averageSimilarity * 100).toFixed(2)}%\n`;
334
+ report += `- **Min Similarity:** ${(result.summary.minSimilarity * 100).toFixed(2)}%\n`;
335
+ report += `- **Max Difference:** ${result.summary.maxDifference.toFixed(2)}%\n\n`;
336
+
337
+ if (!result.passed) {
338
+ report += `**Failed Frames:**\n\n`;
339
+ for (const frame of result.frames.filter((f) => !f.passed)) {
340
+ report += `- Frame ${frame.frameNumber}: ${(frame.similarity * 100).toFixed(2)}% similarity, ${frame.differencePercentage.toFixed(2)}% different\n`;
341
+ if (frame.diffImagePath) {
342
+ report += ` - Diff: ${frame.diffImagePath}\n`;
343
+ }
344
+ }
345
+ report += `\n`;
346
+ }
347
+ }
348
+
349
+ return report;
350
+ }
351
+
352
+ /**
353
+ * Load golden reference image
354
+ */
355
+ private async loadGoldenImage(basePath: string, frameNumber: number): Promise<ImageData> {
356
+ // In a real implementation, this would load from disk
357
+ // For now, return a placeholder
358
+ throw new Error("loadGoldenImage not implemented - requires filesystem access");
359
+ }
360
+
361
+ /**
362
+ * Save diff image for failed comparison
363
+ */
364
+ private async saveDiffImage(testId: string, frameNumber: number, diffImage: ImageData): Promise<string> {
365
+ // In a real implementation, this would save to disk
366
+ // For now, return a placeholder path
367
+ return `./golden-tests/diffs/${testId}_frame${frameNumber}_diff.png`;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Effect Renderer Interface
373
+ * (Implementation would use actual rendering pipeline)
374
+ */
375
+ export interface EffectRenderer {
376
+ renderFrame(effectId: string, parameters: Record<string, any>, inputs: Record<string, any>, frameNumber: number): Promise<ImageData>;
377
+ }
378
+
379
+ /**
380
+ * Convenience function to run golden tests
381
+ */
382
+ export async function runGoldenTests(testCases: GoldenTestCase[], renderer: EffectRenderer): Promise<GoldenTestResult[]> {
383
+ const runner = new GoldenTestRunner();
384
+
385
+ for (const testCase of testCases) {
386
+ runner.registerTest(testCase);
387
+ }
388
+
389
+ return await runner.runAllTests(renderer);
390
+ }