@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,571 @@
1
+ /**
2
+ * Effect Validator
3
+ *
4
+ * Validates effect definitions before publishing to ensure quality and correctness.
5
+ * Checks shader compilation, parameter schemas, graph structure, and metadata completeness.
6
+ *
7
+ * Phase 6 Week 10 - Publishing Pipeline #1
8
+ */
9
+
10
+ export interface ValidationResult {
11
+ valid: boolean;
12
+ errors: ValidationError[];
13
+ warnings: ValidationWarning[];
14
+ metadata: {
15
+ effectId: string;
16
+ effectName: string;
17
+ validatedAt: string;
18
+ validator: string;
19
+ };
20
+ }
21
+
22
+ export interface ValidationError {
23
+ type: "shader" | "schema" | "graph" | "metadata" | "performance";
24
+ severity: "error";
25
+ message: string;
26
+ location?: string;
27
+ suggestion?: string;
28
+ }
29
+
30
+ export interface ValidationWarning {
31
+ type: "shader" | "schema" | "graph" | "metadata" | "performance";
32
+ severity: "warning";
33
+ message: string;
34
+ location?: string;
35
+ suggestion?: string;
36
+ }
37
+
38
+ export interface EffectDefinition {
39
+ id: string;
40
+ name: string;
41
+ version: string;
42
+ category: string;
43
+ description: string;
44
+ schema: {
45
+ parameters: Record<string, any>;
46
+ inputs: Record<string, any>;
47
+ outputs: Record<string, any>;
48
+ };
49
+ nodes: any[];
50
+ edges: any[];
51
+ metadata: {
52
+ author?: string;
53
+ tags?: string[];
54
+ thumbnail?: string;
55
+ previewVideo?: string;
56
+ requiredFeatures?: string[];
57
+ };
58
+ capabilities: {
59
+ temporal: boolean;
60
+ stateful: boolean;
61
+ spatial: boolean;
62
+ geometry: boolean;
63
+ inputsCount: number;
64
+ };
65
+ requirements: {
66
+ temporalRadius: number;
67
+ preferredPrecision: string;
68
+ multipass: boolean;
69
+ supportsHalfResolution: boolean;
70
+ };
71
+ presets?: any[];
72
+ }
73
+
74
+ /**
75
+ * Effect Validator
76
+ */
77
+ export class EffectValidator {
78
+ private errors: ValidationError[] = [];
79
+ private warnings: ValidationWarning[] = [];
80
+
81
+ /**
82
+ * Validate an effect definition
83
+ */
84
+ validate(effect: EffectDefinition): ValidationResult {
85
+ this.errors = [];
86
+ this.warnings = [];
87
+
88
+ // Run validation checks
89
+ this.validateMetadata(effect);
90
+ this.validateSchema(effect);
91
+ this.validateGraph(effect);
92
+ this.validateShaders(effect);
93
+ this.validatePresets(effect);
94
+ this.validatePerformance(effect);
95
+
96
+ return {
97
+ valid: this.errors.length === 0,
98
+ errors: this.errors,
99
+ warnings: this.warnings,
100
+ metadata: {
101
+ effectId: effect.id,
102
+ effectName: effect.name,
103
+ validatedAt: new Date().toISOString(),
104
+ validator: "EffectValidator v1.0.0",
105
+ },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Validate metadata completeness
111
+ */
112
+ private validateMetadata(effect: EffectDefinition): void {
113
+ // Required fields
114
+ if (!effect.id) {
115
+ this.errors.push({
116
+ type: "metadata",
117
+ severity: "error",
118
+ message: "Effect ID is required",
119
+ suggestion: "Add a unique effect ID like 'video.my-effect'",
120
+ });
121
+ } else if (!effect.id.includes(".")) {
122
+ this.warnings.push({
123
+ type: "metadata",
124
+ severity: "warning",
125
+ message: "Effect ID should follow category.name pattern",
126
+ location: `id: "${effect.id}"`,
127
+ suggestion: "Use format like 'video.my-effect' or 'body.my-effect'",
128
+ });
129
+ }
130
+
131
+ if (!effect.name) {
132
+ this.errors.push({
133
+ type: "metadata",
134
+ severity: "error",
135
+ message: "Effect name is required",
136
+ });
137
+ }
138
+
139
+ if (!effect.version || !effect.version.match(/^\d+\.\d+\.\d+$/)) {
140
+ this.errors.push({
141
+ type: "metadata",
142
+ severity: "error",
143
+ message: "Valid semantic version is required (e.g., 1.0.0)",
144
+ location: `version: "${effect.version}"`,
145
+ });
146
+ }
147
+
148
+ if (!effect.description) {
149
+ this.warnings.push({
150
+ type: "metadata",
151
+ severity: "warning",
152
+ message: "Effect description is recommended",
153
+ suggestion: "Add a brief description of what the effect does",
154
+ });
155
+ }
156
+
157
+ if (!effect.category) {
158
+ this.errors.push({
159
+ type: "metadata",
160
+ severity: "error",
161
+ message: "Effect category is required",
162
+ suggestion: "Use 'video', 'transition', 'body', or other category",
163
+ });
164
+ }
165
+
166
+ // Metadata fields
167
+ if (!effect.metadata) {
168
+ this.warnings.push({
169
+ type: "metadata",
170
+ severity: "warning",
171
+ message: "Metadata object is missing",
172
+ suggestion: "Add metadata with author, tags, etc.",
173
+ });
174
+ } else {
175
+ if (!effect.metadata.author) {
176
+ this.warnings.push({
177
+ type: "metadata",
178
+ severity: "warning",
179
+ message: "Author field is recommended",
180
+ });
181
+ }
182
+
183
+ if (!effect.metadata.tags || effect.metadata.tags.length === 0) {
184
+ this.warnings.push({
185
+ type: "metadata",
186
+ severity: "warning",
187
+ message: "Tags are recommended for discoverability",
188
+ suggestion: "Add relevant tags like ['blur', 'artistic', 'mask']",
189
+ });
190
+ }
191
+
192
+ if (!effect.metadata.thumbnail) {
193
+ this.warnings.push({
194
+ type: "metadata",
195
+ severity: "warning",
196
+ message: "Thumbnail is recommended for preview",
197
+ });
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Validate parameter schema
204
+ */
205
+ private validateSchema(effect: EffectDefinition): void {
206
+ if (!effect.schema) {
207
+ this.errors.push({
208
+ type: "schema",
209
+ severity: "error",
210
+ message: "Schema is required",
211
+ });
212
+ return;
213
+ }
214
+
215
+ // Validate parameters
216
+ if (!effect.schema.parameters) {
217
+ this.warnings.push({
218
+ type: "schema",
219
+ severity: "warning",
220
+ message: "No parameters defined",
221
+ suggestion: "Effects typically have at least one adjustable parameter",
222
+ });
223
+ } else {
224
+ for (const [key, param] of Object.entries(effect.schema.parameters)) {
225
+ this.validateParameter(key, param);
226
+ }
227
+ }
228
+
229
+ // Validate inputs
230
+ if (!effect.schema.inputs) {
231
+ this.errors.push({
232
+ type: "schema",
233
+ severity: "error",
234
+ message: "Inputs schema is required",
235
+ });
236
+ }
237
+
238
+ // Validate outputs
239
+ if (!effect.schema.outputs) {
240
+ this.errors.push({
241
+ type: "schema",
242
+ severity: "error",
243
+ message: "Outputs schema is required",
244
+ });
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Validate a single parameter
250
+ */
251
+ private validateParameter(key: string, param: any): void {
252
+ if (!param.type) {
253
+ this.errors.push({
254
+ type: "schema",
255
+ severity: "error",
256
+ message: `Parameter "${key}" missing type`,
257
+ location: `parameters.${key}`,
258
+ });
259
+ }
260
+
261
+ if (param.type === "number") {
262
+ if (param.min === undefined || param.max === undefined) {
263
+ this.warnings.push({
264
+ type: "schema",
265
+ severity: "warning",
266
+ message: `Number parameter "${key}" should have min and max`,
267
+ location: `parameters.${key}`,
268
+ });
269
+ }
270
+
271
+ if (param.default === undefined) {
272
+ this.warnings.push({
273
+ type: "schema",
274
+ severity: "warning",
275
+ message: `Parameter "${key}" should have a default value`,
276
+ location: `parameters.${key}`,
277
+ });
278
+ } else if (param.min !== undefined && param.max !== undefined) {
279
+ if (param.default < param.min || param.default > param.max) {
280
+ this.errors.push({
281
+ type: "schema",
282
+ severity: "error",
283
+ message: `Parameter "${key}" default value out of range`,
284
+ location: `parameters.${key}.default`,
285
+ suggestion: `Default should be between ${param.min} and ${param.max}`,
286
+ });
287
+ }
288
+ }
289
+ }
290
+
291
+ if (!param.label) {
292
+ this.warnings.push({
293
+ type: "schema",
294
+ severity: "warning",
295
+ message: `Parameter "${key}" missing label`,
296
+ location: `parameters.${key}`,
297
+ suggestion: "Add a human-readable label",
298
+ });
299
+ }
300
+
301
+ if (!param.description) {
302
+ this.warnings.push({
303
+ type: "schema",
304
+ severity: "warning",
305
+ message: `Parameter "${key}" missing description`,
306
+ location: `parameters.${key}`,
307
+ suggestion: "Add a description of what this parameter does",
308
+ });
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Validate graph structure
314
+ */
315
+ private validateGraph(effect: EffectDefinition): void {
316
+ if (!effect.nodes || effect.nodes.length === 0) {
317
+ this.errors.push({
318
+ type: "graph",
319
+ severity: "error",
320
+ message: "Effect must have at least one node",
321
+ });
322
+ return;
323
+ }
324
+
325
+ if (!effect.edges || effect.edges.length === 0) {
326
+ this.warnings.push({
327
+ type: "graph",
328
+ severity: "warning",
329
+ message: "Effect has no edges (disconnected nodes)",
330
+ });
331
+ }
332
+
333
+ // Find input and output nodes
334
+ const hasInput = effect.nodes.some((n) => n.type === "Input");
335
+ const hasOutput = effect.nodes.some((n) => n.type === "Output");
336
+
337
+ if (!hasInput) {
338
+ this.errors.push({
339
+ type: "graph",
340
+ severity: "error",
341
+ message: "Graph must have an Input node",
342
+ });
343
+ }
344
+
345
+ if (!hasOutput) {
346
+ this.errors.push({
347
+ type: "graph",
348
+ severity: "error",
349
+ message: "Graph must have an Output node",
350
+ });
351
+ }
352
+
353
+ // Check for duplicate node IDs
354
+ const nodeIds = new Set<string>();
355
+ for (const node of effect.nodes) {
356
+ if (!node.id) {
357
+ this.errors.push({
358
+ type: "graph",
359
+ severity: "error",
360
+ message: "Node missing ID",
361
+ location: `nodes[${effect.nodes.indexOf(node)}]`,
362
+ });
363
+ continue;
364
+ }
365
+
366
+ if (nodeIds.has(node.id)) {
367
+ this.errors.push({
368
+ type: "graph",
369
+ severity: "error",
370
+ message: `Duplicate node ID: "${node.id}"`,
371
+ location: `nodes`,
372
+ });
373
+ }
374
+ nodeIds.add(node.id);
375
+ }
376
+
377
+ // Validate edges reference valid nodes
378
+ for (const edge of effect.edges) {
379
+ if (!nodeIds.has(edge.from)) {
380
+ this.errors.push({
381
+ type: "graph",
382
+ severity: "error",
383
+ message: `Edge references non-existent node: "${edge.from}"`,
384
+ location: `edges`,
385
+ });
386
+ }
387
+
388
+ if (!nodeIds.has(edge.to)) {
389
+ this.errors.push({
390
+ type: "graph",
391
+ severity: "error",
392
+ message: `Edge references non-existent node: "${edge.to}"`,
393
+ location: `edges`,
394
+ });
395
+ }
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Validate shader code
401
+ */
402
+ private validateShaders(effect: EffectDefinition): void {
403
+ const shaderNodes = effect.nodes.filter((n) => n.type === "ShaderNode");
404
+
405
+ if (shaderNodes.length === 0) {
406
+ this.warnings.push({
407
+ type: "shader",
408
+ severity: "warning",
409
+ message: "Effect has no shader nodes",
410
+ suggestion: "Most effects require at least one shader",
411
+ });
412
+ return;
413
+ }
414
+
415
+ for (const node of shaderNodes) {
416
+ if (!node.params || !node.params.shader) {
417
+ this.errors.push({
418
+ type: "shader",
419
+ severity: "error",
420
+ message: `ShaderNode "${node.id}" missing shader code`,
421
+ location: `nodes.${node.id}`,
422
+ });
423
+ continue;
424
+ }
425
+
426
+ const shader = node.params.shader;
427
+
428
+ // Basic GLSL validation
429
+ if (!shader.includes("void main()")) {
430
+ this.errors.push({
431
+ type: "shader",
432
+ severity: "error",
433
+ message: `ShaderNode "${node.id}" shader missing main() function`,
434
+ location: `nodes.${node.id}.params.shader`,
435
+ });
436
+ }
437
+
438
+ if (!shader.includes("gl_FragColor")) {
439
+ this.warnings.push({
440
+ type: "shader",
441
+ severity: "warning",
442
+ message: `ShaderNode "${node.id}" shader doesn't set gl_FragColor`,
443
+ location: `nodes.${node.id}.params.shader`,
444
+ suggestion: "Fragment shaders should set gl_FragColor",
445
+ });
446
+ }
447
+
448
+ if (!shader.includes("precision")) {
449
+ this.warnings.push({
450
+ type: "shader",
451
+ severity: "warning",
452
+ message: `ShaderNode "${node.id}" shader missing precision qualifier`,
453
+ location: `nodes.${node.id}.params.shader`,
454
+ suggestion: "Add 'precision highp float;' or 'precision mediump float;'",
455
+ });
456
+ }
457
+
458
+ // Check for common mistakes
459
+ if (shader.includes("texture2D") && !shader.includes("sampler2D")) {
460
+ this.warnings.push({
461
+ type: "shader",
462
+ severity: "warning",
463
+ message: `ShaderNode "${node.id}" uses texture2D but no sampler2D uniforms`,
464
+ location: `nodes.${node.id}.params.shader`,
465
+ });
466
+ }
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Validate presets
472
+ */
473
+ private validatePresets(effect: EffectDefinition): void {
474
+ if (!effect.presets || effect.presets.length === 0) {
475
+ this.warnings.push({
476
+ type: "metadata",
477
+ severity: "warning",
478
+ message: "Effect has no presets",
479
+ suggestion: "Add at least 2-3 presets to demonstrate effect range",
480
+ });
481
+ return;
482
+ }
483
+
484
+ if (effect.presets.length < 2) {
485
+ this.warnings.push({
486
+ type: "metadata",
487
+ severity: "warning",
488
+ message: "Effect has only one preset",
489
+ suggestion: "Add more presets to show different use cases",
490
+ });
491
+ }
492
+
493
+ for (const preset of effect.presets) {
494
+ if (!preset.id) {
495
+ this.errors.push({
496
+ type: "metadata",
497
+ severity: "error",
498
+ message: "Preset missing ID",
499
+ });
500
+ }
501
+
502
+ if (!preset.name) {
503
+ this.errors.push({
504
+ type: "metadata",
505
+ severity: "error",
506
+ message: "Preset missing name",
507
+ });
508
+ }
509
+
510
+ if (!preset.parameters) {
511
+ this.errors.push({
512
+ type: "metadata",
513
+ severity: "error",
514
+ message: `Preset "${preset.id}" missing parameters`,
515
+ });
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Validate performance characteristics
522
+ */
523
+ private validatePerformance(effect: EffectDefinition): void {
524
+ if (!effect.requirements) {
525
+ this.warnings.push({
526
+ type: "performance",
527
+ severity: "warning",
528
+ message: "Performance requirements not specified",
529
+ });
530
+ return;
531
+ }
532
+
533
+ const shaderNodeCount = effect.nodes.filter((n) => n.type === "ShaderNode").length;
534
+
535
+ if (shaderNodeCount > 10) {
536
+ this.warnings.push({
537
+ type: "performance",
538
+ severity: "warning",
539
+ message: `Effect has ${shaderNodeCount} shader passes`,
540
+ suggestion: "Consider optimizing to reduce pass count",
541
+ });
542
+ }
543
+
544
+ if (effect.requirements.multipass && shaderNodeCount === 1) {
545
+ this.warnings.push({
546
+ type: "performance",
547
+ severity: "warning",
548
+ message: "Effect marked as multipass but has only one shader node",
549
+ location: "requirements.multipass",
550
+ });
551
+ }
552
+
553
+ if (!effect.requirements.multipass && shaderNodeCount > 1) {
554
+ this.warnings.push({
555
+ type: "performance",
556
+ severity: "warning",
557
+ message: "Effect has multiple passes but not marked as multipass",
558
+ location: "requirements.multipass",
559
+ suggestion: "Set multipass: true",
560
+ });
561
+ }
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Convenience function to validate an effect
567
+ */
568
+ export function validateEffect(effect: EffectDefinition): ValidationResult {
569
+ const validator = new EffectValidator();
570
+ return validator.validate(effect);
571
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @clypra/runtime — Validation
3
+ *
4
+ * Runtime validation for shaders, resources, and graphs.
5
+ */
6
+
7
+ export * from "./types";
8
+ export * from "./shader-validator";
9
+ export * from "./resource-validator";