@fugood/bricks-ctor 2.25.0-beta.7 → 2.25.0-beta.9

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,115 @@
1
+ import { compile } from '../index'
2
+
3
+ const SUBSPACE_ID = 'SUBSPACE_00000000-0000-0000-0000-000000000001'
4
+ const CANVAS_ID = 'CANVAS_00000000-0000-0000-0000-000000000001'
5
+ const ANIMATION_ID = 'ANIMATION_00000000-0000-0000-0000-000000000001'
6
+
7
+ const makeApp = (animations) => {
8
+ const rootCanvas = {
9
+ __typename: 'Canvas',
10
+ id: CANVAS_ID,
11
+ items: [],
12
+ }
13
+ const rootSubspace = {
14
+ __typename: 'Subspace',
15
+ id: SUBSPACE_ID,
16
+ title: 'Main Subspace',
17
+ layout: {
18
+ width: 96,
19
+ height: 54,
20
+ },
21
+ rootCanvas,
22
+ canvases: [rootCanvas],
23
+ animations,
24
+ bricks: [],
25
+ generators: [],
26
+ data: [],
27
+ dataRouting: [],
28
+ dataCalculation: [],
29
+ }
30
+
31
+ return {
32
+ name: 'Compile animation test',
33
+ rootSubspace,
34
+ subspaces: [rootSubspace],
35
+ }
36
+ }
37
+
38
+ describe('compile animations', () => {
39
+ test('normalizes mixed legacy spring config', async () => {
40
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
41
+ try {
42
+ const config = await compile(
43
+ makeApp([
44
+ {
45
+ __typename: 'Animation',
46
+ id: ANIMATION_ID,
47
+ alias: 'btnPressOut',
48
+ title: 'Button Press Out',
49
+ property: 'transform.scale',
50
+ config: {
51
+ __type: 'AnimationSpringConfig',
52
+ toValue: 1,
53
+ friction: 5,
54
+ tension: 200,
55
+ speed: 14,
56
+ bounciness: 8,
57
+ },
58
+ },
59
+ ]),
60
+ )
61
+
62
+ expect(config.subspace_map[SUBSPACE_ID].animation_map[ANIMATION_ID].config).toEqual({
63
+ toValue: 1,
64
+ friction: 5,
65
+ tension: 200,
66
+ })
67
+ const warning = warnSpy.mock.calls[0][0]
68
+ expect(warning).toContain('Resolved animation spring config')
69
+ expect(warning).toContain('Button Press Out')
70
+ expect(warning).toContain('btnPressOut')
71
+ expect(warning).toContain('transform.scale')
72
+ expect(warning).toContain('Main Subspace')
73
+ expect(warning).not.toContain('ANIMATION_')
74
+ expect(warning).not.toContain('SUBSPACE_')
75
+ } finally {
76
+ warnSpy.mockRestore()
77
+ }
78
+ })
79
+
80
+ test('rejects invalid animation property', async () => {
81
+ await expect(
82
+ compile(
83
+ makeApp([
84
+ {
85
+ __typename: 'Animation',
86
+ id: ANIMATION_ID,
87
+ property: 'transform.skewX',
88
+ config: {
89
+ __type: 'AnimationTimingConfig',
90
+ toValue: 1,
91
+ },
92
+ },
93
+ ]),
94
+ ),
95
+ ).rejects.toThrow(/Invalid animation property/)
96
+ })
97
+
98
+ test('rejects invalid animation config type', async () => {
99
+ await expect(
100
+ compile(
101
+ makeApp([
102
+ {
103
+ __typename: 'Animation',
104
+ id: ANIMATION_ID,
105
+ property: 'opacity',
106
+ config: {
107
+ __type: 'AnimationUnknownConfig',
108
+ toValue: 1,
109
+ },
110
+ },
111
+ ]),
112
+ ),
113
+ ).rejects.toThrow(/Invalid animation config type/)
114
+ })
115
+ })
package/compile/index.ts CHANGED
@@ -301,6 +301,162 @@ const animationTypeMap = {
301
301
  AnimationTimingConfig: 'timing',
302
302
  AnimationSpringConfig: 'spring',
303
303
  AnimationDecayConfig: 'decay',
304
+ } as const
305
+
306
+ type CompiledAnimationType = (typeof animationTypeMap)[keyof typeof animationTypeMap]
307
+ type WarningMetadata = Record<string, unknown>
308
+
309
+ const animationProperties = new Set([
310
+ 'transform.translateX',
311
+ 'transform.translateY',
312
+ 'transform.scale',
313
+ 'transform.scaleX',
314
+ 'transform.scaleY',
315
+ 'transform.rotate',
316
+ 'transform.rotateX',
317
+ 'transform.rotateY',
318
+ 'opacity',
319
+ ])
320
+
321
+ const animationComposeTypes = new Set(['parallel', 'sequence'])
322
+ const springConfigFamilies = [
323
+ ['stiffness', 'damping', 'mass'],
324
+ ['tension', 'friction'],
325
+ ['bounciness', 'speed'],
326
+ ]
327
+ const springConfigFamilyKeys = new Set(springConfigFamilies.flat())
328
+
329
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
330
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
331
+
332
+ const hasDefinedConfigValue = (config: Record<string, unknown>, key: string) =>
333
+ config[key] !== undefined
334
+
335
+ const assertConfigValue = (
336
+ config: Record<string, unknown>,
337
+ key: string,
338
+ errorReference: string,
339
+ ) => {
340
+ if (!hasDefinedConfigValue(config, key)) {
341
+ throw new Error(`Invalid animation config ${errorReference}: missing "${key}"`)
342
+ }
343
+ }
344
+
345
+ const assertAnimationProperty = (property: unknown, errorReference: string) => {
346
+ if (typeof property !== 'string' || !animationProperties.has(property)) {
347
+ throw new Error(
348
+ `Invalid animation property${errorReference ? ` ${errorReference}` : ''}: ${String(
349
+ property,
350
+ )}`,
351
+ )
352
+ }
353
+ return property
354
+ }
355
+
356
+ const getAnimationType = (config: unknown, errorReference: string): CompiledAnimationType => {
357
+ if (!isRecord(config)) {
358
+ throw new Error(`Invalid animation config ${errorReference}: config must be an object`)
359
+ }
360
+
361
+ const animationType = animationTypeMap[config.__type as keyof typeof animationTypeMap]
362
+ if (!animationType) {
363
+ throw new Error(`Invalid animation config type ${errorReference}: ${String(config.__type)}`)
364
+ }
365
+ return animationType
366
+ }
367
+
368
+ const assertAnimationComposeType = (composeType: unknown, errorReference: string) => {
369
+ if (typeof composeType !== 'string' || !animationComposeTypes.has(composeType)) {
370
+ throw new Error(`Invalid animation compose type ${errorReference}: ${String(composeType)}`)
371
+ }
372
+ return composeType
373
+ }
374
+
375
+ const pickDefinedConfigValues = (config: Record<string, unknown>, keys: string[]) =>
376
+ keys.reduce((acc, key) => {
377
+ if (hasDefinedConfigValue(config, key)) acc[key] = config[key]
378
+ return acc
379
+ }, {})
380
+
381
+ const getDefinedConfigKeys = (config: Record<string, unknown>, keys: string[]) =>
382
+ keys.filter((key) => hasDefinedConfigValue(config, key))
383
+
384
+ const formatWarningMetadata = (metadata: WarningMetadata = {}) =>
385
+ Object.entries(metadata)
386
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
387
+ .map(([key, value]) => `${key}: ${String(value)}`)
388
+ .join(', ')
389
+
390
+ const formatWarningReference = (metadata?: WarningMetadata) => {
391
+ const metadataText = formatWarningMetadata(metadata)
392
+ return metadataText ? ` [${metadataText}]` : ''
393
+ }
394
+
395
+ const normalizeSpringConfig = (
396
+ config: Record<string, unknown>,
397
+ errorReference: string,
398
+ warningMetadata?: WarningMetadata,
399
+ ): Record<string, unknown> => {
400
+ assertConfigValue(config, 'toValue', errorReference)
401
+
402
+ const usedFamilies = springConfigFamilies.filter((keys) =>
403
+ keys.some((key) => hasDefinedConfigValue(config, key)),
404
+ )
405
+ if (usedFamilies.length <= 1) return config
406
+
407
+ const configWithoutSpringFamily = Object.entries(config).reduce((acc, [key, value]) => {
408
+ if (!springConfigFamilyKeys.has(key)) acc[key] = value
409
+ return acc
410
+ }, {})
411
+
412
+ // Match runtime normalization: physical spring values are most explicit,
413
+ // otherwise preserve BRICKS' historical tension/friction controls.
414
+ const resolvedFamily =
415
+ usedFamilies.find((keys) => keys.includes('stiffness')) ||
416
+ usedFamilies.find((keys) => keys.includes('tension')) ||
417
+ usedFamilies[0]
418
+ const resolvedFamilyKeys = getDefinedConfigKeys(config, resolvedFamily)
419
+ const droppedFamilyKeys = usedFamilies
420
+ .filter((keys) => keys !== resolvedFamily)
421
+ .flatMap((keys) => getDefinedConfigKeys(config, keys))
422
+
423
+ console.warn(
424
+ `[Warning] Resolved animation spring config${formatWarningReference(
425
+ warningMetadata,
426
+ )}: using ${resolvedFamilyKeys.join('/')}, dropping ${droppedFamilyKeys.join('/')}`,
427
+ )
428
+
429
+ return {
430
+ ...configWithoutSpringFamily,
431
+ ...pickDefinedConfigValues(config, resolvedFamily),
432
+ }
433
+ }
434
+
435
+ const compileAnimationConfig = (
436
+ animationType: CompiledAnimationType,
437
+ config: unknown,
438
+ errorReference: string,
439
+ warningMetadata?: WarningMetadata,
440
+ ) => {
441
+ if (!isRecord(config)) {
442
+ throw new Error(`Invalid animation config ${errorReference}: config must be an object`)
443
+ }
444
+
445
+ const compiledConfig = compileProperty(omit(config, '__type'), errorReference)
446
+
447
+ if (!isRecord(compiledConfig)) {
448
+ throw new Error(`Invalid animation config ${errorReference}: config must compile to an object`)
449
+ }
450
+
451
+ if (animationType === 'timing') {
452
+ assertConfigValue(compiledConfig, 'toValue', errorReference)
453
+ } else if (animationType === 'spring') {
454
+ return normalizeSpringConfig(compiledConfig, errorReference, warningMetadata)
455
+ } else if (animationType === 'decay') {
456
+ assertConfigValue(compiledConfig, 'velocity', errorReference)
457
+ }
458
+
459
+ return compiledConfig
304
460
  }
305
461
 
306
462
  const compileFrame = (frame: Canvas['items'][number]['frame']) => ({
@@ -629,39 +785,63 @@ export const compile = async (app: Application) => {
629
785
  `(animation index: ${animationIndex}, subspace: ${subspaceId})`,
630
786
  )
631
787
 
632
- if (animation.__typename === 'Animation') {
788
+ const animationTypename = animation.__typename
789
+ if (animationTypename === 'Animation') {
633
790
  const animationDef = animation as AnimationDef
791
+ const animationErrorReference = `(animation: ${animationId}, subspace ${subspaceId})`
792
+ const animationWarningMetadata = {
793
+ animationIndex,
794
+ animationTitle: animationDef.title,
795
+ animationAlias: animationDef.alias,
796
+ animationProperty: animationDef.property,
797
+ subspaceIndex,
798
+ subspaceTitle: subspace.title,
799
+ }
800
+ const animationType = getAnimationType(animationDef.config, animationErrorReference)
634
801
  map[animationId] = {
635
802
  alias: animationDef.alias,
636
803
  title: animationDef.title,
637
804
  description: animationDef.description,
638
805
  hide_short_ref: animationDef.hideShortRef,
639
806
  animationRunType: animationDef.runType,
640
- property: animationDef.property,
641
- type: animationTypeMap[animationDef.config.__type],
642
- config: compileProperty(
643
- omit(animationDef.config, '__type'),
644
- `(animation: ${animationId}, subspace ${subspaceId})`,
807
+ property: assertAnimationProperty(animationDef.property, animationErrorReference),
808
+ type: animationType,
809
+ config: compileAnimationConfig(
810
+ animationType,
811
+ animationDef.config,
812
+ animationErrorReference,
813
+ animationWarningMetadata,
645
814
  ),
646
815
  }
647
- } else if (animation.__typename === 'AnimationCompose') {
816
+ } else if (animationTypename === 'AnimationCompose') {
648
817
  const animationDef = animation as AnimationComposeDef
818
+ const animationErrorReference = `(animation: ${animationId}, subspace ${subspaceId})`
649
819
  map[animationId] = {
650
820
  alias: animationDef.alias,
651
821
  title: animationDef.title,
652
822
  description: animationDef.description,
653
823
  hide_short_ref: animationDef.hideShortRef,
654
824
  animationRunType: animationDef.runType,
655
- compose_type: animationDef.composeType,
825
+ compose_type: assertAnimationComposeType(
826
+ animationDef.composeType,
827
+ animationErrorReference,
828
+ ),
656
829
  item_list: animationDef.items.map((item, index) => {
657
830
  const innerAnimation = item()
658
- if (!innerAnimation?.id)
659
- throw new Error(
660
- `Invalid animation index: ${index} (animation: ${innerAnimation.id}, subspace ${subspaceId})`,
661
- )
662
- return { animation_id: innerAnimation.id }
831
+ const innerAnimationId = assertEntryId(
832
+ innerAnimation?.id,
833
+ 'ANIMATION',
834
+ `(animation item index: ${index}, animation: ${animationId}, subspace ${subspaceId})`,
835
+ )
836
+ return { animation_id: innerAnimationId }
663
837
  }),
664
838
  }
839
+ } else {
840
+ throw new Error(
841
+ `Invalid animation typename (animation: ${animationId}, subspace ${subspaceId}): ${String(
842
+ animationTypename,
843
+ )}`,
844
+ )
665
845
  }
666
846
  return map
667
847
  }, {}),
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.7",
3
+ "version": "2.25.0-beta.9",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
7
7
  "build": "bun scripts/build.js"
8
8
  },
9
9
  "dependencies": {
10
- "@fugood/bricks-cli": "^2.25.0-beta.6",
10
+ "@fugood/bricks-cli": "^2.25.0-beta.9",
11
11
  "@huggingface/gguf": "^0.3.2",
12
12
  "@iarna/toml": "^3.0.0",
13
13
  "@modelcontextprotocol/sdk": "^1.15.0",
@@ -25,5 +25,5 @@
25
25
  "peerDependencies": {
26
26
  "oxfmt": "^0.36.0"
27
27
  },
28
- "gitHead": "e43200271cec0b015b1044e1b0041cad575f030f"
28
+ "gitHead": "9274cc21f1e882d26f80e45a972d814d648c861a"
29
29
  }
@@ -41,12 +41,13 @@ const bounce: AnimationDef = {
41
41
  toValue: 1,
42
42
  friction: 7,
43
43
  tension: 40,
44
- speed: 12,
45
- bounciness: 8,
46
44
  },
47
45
  }
48
46
  ```
49
47
 
48
+ Use one spring parameter family per config: `tension/friction`, `speed/bounciness`, or
49
+ `stiffness/damping/mass`.
50
+
50
51
  ### Decay Animation
51
52
  Velocity-based deceleration animation.
52
53
 
@@ -6,6 +6,12 @@ import { createServer } from 'http'
6
6
  import { parseArgs } from 'util'
7
7
  import * as TOON from '@toon-format/toon'
8
8
 
9
+ for (const stream of [process.stdout, process.stderr]) {
10
+ stream.on('error', (err) => {
11
+ if (err.code !== 'EPIPE') throw err
12
+ })
13
+ }
14
+
9
15
  const { values } = parseArgs({
10
16
  args: process.argv,
11
17
  options: {
@@ -224,16 +230,23 @@ app.on('ready', () => {
224
230
  )
225
231
  if (takeScreenshotConfig) {
226
232
  const { delay, width, height, path } = takeScreenshotConfig
227
- setTimeout(() => {
233
+ setTimeout(async () => {
234
+ let screenshotFailed = false
228
235
  console.log('Taking screenshot')
229
- mainWindow.webContents.capturePage().then((image) => {
236
+ try {
237
+ const image = await mainWindow.webContents.capturePage()
230
238
  console.log('Writing screenshot to', path)
231
- writeFile(path, image.resize({ width, height }).toJPEG(75))
239
+ await writeFile(path, image.resize({ width, height }).toJPEG(75))
240
+ } catch (err) {
241
+ screenshotFailed = true
242
+ console.error('Screenshot capture/write failed', err)
243
+ } finally {
232
244
  if (noKeepOpen) {
233
245
  console.log('Closing app')
234
- app.quit()
246
+ if (screenshotFailed) app.exit(1)
247
+ else app.quit()
235
248
  }
236
- })
249
+ }
237
250
  }, delay)
238
251
  }
239
252
  }
@@ -44,10 +44,21 @@ export interface AnimationTimingConfig {
44
44
  export interface AnimationSpringConfig {
45
45
  __type: 'AnimationSpringConfig'
46
46
  toValue: number // BRICKS Grid unit
47
- friction: number
48
- tension: number
49
- speed: number
50
- bounciness: number
47
+ // Use one spring parameter family: tension/friction, speed/bounciness,
48
+ // or stiffness/damping/mass.
49
+ friction?: number
50
+ tension?: number
51
+ speed?: number
52
+ bounciness?: number
53
+ stiffness?: number
54
+ damping?: number
55
+ mass?: number
56
+ velocity?: number
57
+ delay?: number
58
+ isInteraction?: boolean
59
+ overshootClamping?: boolean
60
+ restDisplacementThreshold?: number
61
+ restSpeedThreshold?: number
51
62
  }
52
63
 
53
64
  export interface AnimationDecayConfig {
@@ -713,6 +713,7 @@ Default property:
713
713
  | {
714
714
  enabled?: boolean | DataLink
715
715
  url?: string | DataLink
716
+ autoDiscoverType?: 'manual' | 'auto' | DataLink
716
717
  fallbackType?: 'use-local' | 'no-op' | DataLink
717
718
  strategy?: 'prefer-local' | 'prefer-buttress' | 'prefer-best' | DataLink
718
719
  }
@@ -158,6 +158,7 @@ Default property:
158
158
  | {
159
159
  enabled?: boolean | DataLink
160
160
  url?: string | DataLink
161
+ autoDiscoverType?: 'manual' | 'auto' | DataLink
161
162
  fallbackType?: 'use-local' | 'no-op' | DataLink
162
163
  strategy?: 'prefer-local' | 'prefer-buttress' | 'prefer-best' | DataLink
163
164
  }
@@ -323,6 +323,7 @@ Default property:
323
323
  | {
324
324
  enabled?: boolean | DataLink
325
325
  url?: string | DataLink
326
+ autoDiscoverType?: 'manual' | 'auto' | DataLink
326
327
  fallbackType?: 'use-local' | 'no-op' | DataLink
327
328
  strategy?: 'prefer-local' | 'prefer-buttress' | 'prefer-best' | DataLink
328
329
  }
@@ -0,0 +1,58 @@
1
+ import { makeId } from '../id'
2
+
3
+ const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
4
+ const UUID_ONLY_RE = new RegExp(`^${UUID}$`)
5
+
6
+ describe('makeId', () => {
7
+ describe('type prefixes', () => {
8
+ const cases = [
9
+ ['animation', 'ANIMATION_'],
10
+ ['brick', 'BRICK_'],
11
+ ['dynamic-brick', 'DYNAMIC_BRICK_'],
12
+ ['canvas', 'CANVAS_'],
13
+ ['generator', 'GENERATOR_'],
14
+ ['data', 'PROPERTY_BANK_DATA_NODE_'],
15
+ ['switch', 'BRICK_STATE_GROUP_'],
16
+ ['property_bank_command', 'PROPERTY_BANK_COMMAND_NODE_'],
17
+ ['property_bank_calc', 'PROPERTY_BANK_COMMAND_MAP_'],
18
+ ['automation_map', 'AUTOMATION_MAP_'],
19
+ ['test', 'TEST_'],
20
+ ['test_case', 'TEST_CASE_'],
21
+ ['test_var', 'TEST_VAR_'],
22
+ ]
23
+
24
+ test.each(cases)('produces %p prefix', (type, prefix) => {
25
+ expect(makeId(type)).toMatch(new RegExp(`^${prefix}${UUID}$`))
26
+ })
27
+
28
+ test('returns unique random uuids across calls', () => {
29
+ expect(makeId('brick')).not.toBe(makeId('brick'))
30
+ })
31
+
32
+ test('unknown type falls through to an unprefixed uuid', () => {
33
+ expect(makeId('not-a-real-type')).toMatch(UUID_ONLY_RE)
34
+ })
35
+ })
36
+
37
+ describe('subspace', () => {
38
+ test('throws because subspace IDs must be fixed', () => {
39
+ expect(() => makeId('subspace')).toThrow(/subspace is not supported/)
40
+ })
41
+ })
42
+
43
+ describe('snapshotMode', () => {
44
+ test('produces sequential, deterministic uuids', () => {
45
+ const a = makeId('brick', { snapshotMode: true })
46
+ const b = makeId('brick', { snapshotMode: true })
47
+ expect(a).toMatch(/^BRICK_00000000-0000-0000-0000-\d{12}$/)
48
+ expect(b).toMatch(/^BRICK_00000000-0000-0000-0000-\d{12}$/)
49
+ expect(Number(b.slice(-12))).toBe(Number(a.slice(-12)) + 1)
50
+ })
51
+
52
+ test('snapshotMode false falls back to random uuid', () => {
53
+ const id = makeId('canvas', { snapshotMode: false })
54
+ expect(id).toMatch(new RegExp(`^CANVAS_${UUID}$`))
55
+ expect(id).not.toMatch(/CANVAS_00000000-0000-0000-0000-/)
56
+ })
57
+ })
58
+ })