@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.
- package/.releaserc.json +16 -0
- package/package.json +47 -0
- package/src/__tests__/integration.test.ts +345 -0
- package/src/graph/__tests__/builder.test.ts +204 -0
- package/src/graph/__tests__/validator.test.ts +381 -0
- package/src/graph/builder.ts +263 -0
- package/src/graph/index.ts +14 -0
- package/src/graph/types.ts +176 -0
- package/src/graph/validator.ts +208 -0
- package/src/index.ts +28 -0
- package/src/pixi/filters.ts +98 -0
- package/src/pixi/index.ts +11 -0
- package/src/pixi/renderer.ts +375 -0
- package/src/pixi/texture-pool.ts +159 -0
- package/src/pixi/types.ts +58 -0
- package/src/planner/index.ts +10 -0
- package/src/planner/optimizer.ts +247 -0
- package/src/planner/planner.ts +201 -0
- package/src/planner/types.ts +56 -0
- package/src/resources/cache.ts +166 -0
- package/src/resources/index.ts +9 -0
- package/src/resources/manager.ts +184 -0
- package/src/resources/types.ts +29 -0
- package/src/testing/benchmarkRunner.ts +399 -0
- package/src/testing/goldenTests.ts +390 -0
- package/src/validation/effectValidator.ts +571 -0
- package/src/validation/index.ts +9 -0
- package/src/validation/resource-validator.ts +173 -0
- package/src/validation/shader-validator.ts +154 -0
- package/src/validation/types.ts +31 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +18 -0
|
@@ -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
|
+
}
|