@routevn/creator-model 1.13.2 → 1.14.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.
Files changed (3) hide show
  1. package/README.md +34 -8
  2. package/package.json +1 -1
  3. package/src/model.js +124 -149
package/README.md CHANGED
@@ -106,6 +106,22 @@ Design rules:
106
106
  - random ids across RouteVN should use `nanoid` with the RouteVN base58 variant;
107
107
  deterministic derived tokens such as partition hashes are a separate case
108
108
 
109
+ ## Animation Camera Tracks
110
+
111
+ Animation resources may explicitly group synchronized position and scale tracks
112
+ with `cameraTracks: ["update"]`, or `["prev", "next"]` for transitions.
113
+ Each listed side must contain `x`, `y`, `scaleX`, and `scaleY` keyframe
114
+ tracks with matching timing/easing and start-value presence, absolute values,
115
+ and positive scales. Track initial values are optional; they must be numeric
116
+ on all four tracks or omitted from all four to use the target's existing pose.
117
+ Translation tracks cannot coexist with Camera on the same side.
118
+
119
+ This authoring marker does not alter the runtime animation format. Existing
120
+ animations without the marker remain separate tracks; no grouping is inferred.
121
+ An update may use `cameraTracks: []` to remove grouping. Updates to animation
122
+ data are validated with the resource's retained grouping unless it is replaced
123
+ in the same command.
124
+
109
125
  ## Font Weight Metadata
110
126
 
111
127
  Font resources may store extracted weight capabilities in three flat fields:
@@ -354,8 +370,17 @@ Audio effects are foldered resources stored in `audioEffects.items` and
354
370
  },
355
371
  audioEffect: {
356
372
  type: "transition",
357
- prev: { fade: { duration: 600, easing: "easeInOutSine" } },
358
- next: { fade: { duration: 900, easing: "easeInOutSine" } },
373
+ prev: {
374
+ volume: {
375
+ keyframes: [{ value: 0, duration: 600, easing: "easeInOutSine" }],
376
+ },
377
+ },
378
+ next: {
379
+ volume: {
380
+ initialValue: 0,
381
+ keyframes: [{ value: 100, duration: 900, easing: "easeInOutSine" }],
382
+ },
383
+ },
359
384
  },
360
385
  }
361
386
  ```
@@ -365,12 +390,13 @@ transition effect, or a single `target` sound slot for an update effect. Each
365
390
  slot is an object containing a `soundId`.
366
391
 
367
392
  `audioEffect.type` is either `transition` or `update`. Transition effects have
368
- at least one `prev.fade` or `next.fade`. Update effects tween one or more of
369
- `volume`, `pan`, and `playbackRate`; each property has non-empty keyframes and
370
- must finish with an absolute numeric keyframe. That final number becomes the
371
- persistent BGM property value after the effect finishes. Absolute volume values
372
- are bounded to `0..100`, pan to `-1..1`, and playback rate to `>= 0`; relative
373
- keyframes represent unbounded numeric deltas and cannot be final.
393
+ at least one property under `prev` or `next`; update effects place properties
394
+ under `tween`. Both support only `volume`, `pan`, and `playbackRate`. Each
395
+ property has non-empty keyframes and must finish with an absolute numeric
396
+ keyframe. A final `tween` or `next` value becomes the persistent BGM property
397
+ value after the effect finishes. Absolute volume values are bounded to
398
+ `0..100`, pan to `-1..1`, and playback rate to `>= 0`; relative keyframes
399
+ represent unbounded numeric deltas and cannot be final.
374
400
 
375
401
  ## Current Scope
376
402
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.13.2",
3
+ "version": "1.14.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/model.js CHANGED
@@ -344,7 +344,7 @@ const ANIMATION_EASING_KEYS = [
344
344
  "easeOutElastic",
345
345
  "easeInOutElastic",
346
346
  ];
347
- const AUDIO_EFFECT_TWEEN_PROPERTY_KEYS = ["volume", "pan", "playbackRate"];
347
+ const AUDIO_EFFECT_PROPERTY_KEYS = ["volume", "pan", "playbackRate"];
348
348
  const VARIABLE_SCOPE_KEYS = ["context", "device", "account"];
349
349
  const VARIABLE_TYPE_KEYS = ["string", "number", "boolean", "object"];
350
350
  const LAYOUT_TYPE_KEYS = [
@@ -408,7 +408,7 @@ const SAVE_LOAD_DATE_FORMATS = new Set([
408
408
  "DD MMM YYYY",
409
409
  "YYYY年MM月DD日",
410
410
  ]);
411
- export const SCHEMA_VERSION = 13;
411
+ export const SCHEMA_VERSION = 14;
412
412
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
413
413
  "folder",
414
414
  "container",
@@ -2345,6 +2345,85 @@ const validateAnimationDefinition = ({ animation, path, errorFactory }) => {
2345
2345
  }
2346
2346
  };
2347
2347
 
2348
+ // Camera is an authored grouping of four synchronized runtime tween tracks.
2349
+ // No grouping is inferred for older animations that omit cameraTracks.
2350
+ const validateCameraTracks = ({ value, animation, path, errorFactory }) => {
2351
+ if (value === undefined) return;
2352
+ if (
2353
+ !Array.isArray(value) ||
2354
+ value.some((side) => !["update", "prev", "next"].includes(side)) ||
2355
+ new Set(value).size !== value.length
2356
+ ) {
2357
+ return invalidFromErrorFactory(
2358
+ errorFactory,
2359
+ `${path} must be an array of unique update, prev, or next sides`,
2360
+ );
2361
+ }
2362
+ // Partial update payloads are checked against the merged resource below.
2363
+ if (animation === undefined) return;
2364
+ for (const side of value) {
2365
+ const tween = side === "update" ? animation.tween : animation[side]?.tween;
2366
+ const validSide =
2367
+ animation.type === "update" ? side === "update" : side !== "update";
2368
+ const properties = ["x", "y", "scaleX", "scaleY"];
2369
+ const tracks = properties.map((property) => tween?.[property]);
2370
+ if (
2371
+ !validSide ||
2372
+ tracks.some(
2373
+ (track) =>
2374
+ !Array.isArray(track?.keyframes) ||
2375
+ (track.initialValue !== undefined &&
2376
+ !Number.isFinite(track.initialValue)) ||
2377
+ (track.initialValue !== undefined) !==
2378
+ (tracks[0]?.initialValue !== undefined),
2379
+ ) ||
2380
+ tween.translateX !== undefined ||
2381
+ tween.translateY !== undefined
2382
+ ) {
2383
+ return invalidFromErrorFactory(
2384
+ errorFactory,
2385
+ `${path}.${side} requires x, y, scaleX, and scaleY keyframe tracks with matching initial-value presence and no translation tracks`,
2386
+ );
2387
+ }
2388
+ const reference = tracks[0].keyframes;
2389
+ for (const [index, track] of tracks.entries()) {
2390
+ if (
2391
+ track.keyframes.length !== reference.length ||
2392
+ track.keyframes.some((frame, frameIndex) => {
2393
+ const other = reference[frameIndex];
2394
+ return (
2395
+ frame.relative === true ||
2396
+ frame.duration !== other.duration ||
2397
+ (frame.delay ?? 0) !== (other.delay ?? 0) ||
2398
+ (frame.easing ?? "linear") !== (other.easing ?? "linear") ||
2399
+ (frame.startValue !== undefined) !==
2400
+ (other.startValue !== undefined)
2401
+ );
2402
+ })
2403
+ ) {
2404
+ return invalidFromErrorFactory(
2405
+ errorFactory,
2406
+ `${path}.${side} requires synchronized absolute keyframes`,
2407
+ );
2408
+ }
2409
+ if (
2410
+ index >= 2 &&
2411
+ ((track.initialValue !== undefined && track.initialValue <= 0) ||
2412
+ track.keyframes.some(
2413
+ (frame) =>
2414
+ frame.value <= 0 ||
2415
+ (frame.startValue !== undefined && frame.startValue <= 0),
2416
+ ))
2417
+ ) {
2418
+ return invalidFromErrorFactory(
2419
+ errorFactory,
2420
+ `${path}.${side} requires positive camera scales`,
2421
+ );
2422
+ }
2423
+ }
2424
+ }
2425
+ };
2426
+
2348
2427
  const validateAnimationItems = ({ items, path, errorFactory }) => {
2349
2428
  for (const [itemId, item] of Object.entries(items)) {
2350
2429
  const itemPath = `${path}.${itemId}`;
@@ -2370,6 +2449,7 @@ const validateAnimationItems = ({ items, path, errorFactory }) => {
2370
2449
  "tagIds",
2371
2450
  "thumbnailFileId",
2372
2451
  "preview",
2452
+ "cameraTracks",
2373
2453
  "animation",
2374
2454
  ],
2375
2455
  path: itemPath,
@@ -2452,6 +2532,13 @@ const validateAnimationItems = ({ items, path, errorFactory }) => {
2452
2532
  return result;
2453
2533
  }
2454
2534
  }
2535
+ const cameraResult = validateCameraTracks({
2536
+ value: item.cameraTracks,
2537
+ animation: item.animation,
2538
+ path: `${itemPath}.cameraTracks`,
2539
+ errorFactory,
2540
+ });
2541
+ if (cameraResult?.valid === false) return cameraResult;
2455
2542
  }
2456
2543
  }
2457
2544
  };
@@ -2656,23 +2743,23 @@ const validateAudioEffectKeyframes = ({
2656
2743
  }
2657
2744
  };
2658
2745
 
2659
- const validateAudioEffectTween = ({ tween, path, errorFactory }) => {
2660
- if (!isPlainObject(tween)) {
2746
+ const validateAudioEffectPropertyTracks = ({ tracks, path, errorFactory }) => {
2747
+ if (!isPlainObject(tracks)) {
2661
2748
  return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
2662
2749
  }
2663
2750
 
2664
- if (Object.keys(tween).length === 0) {
2751
+ if (Object.keys(tracks).length === 0) {
2665
2752
  return invalidFromErrorFactory(
2666
2753
  errorFactory,
2667
- `${path} must contain at least one tween property`,
2754
+ `${path} must contain at least one audio property`,
2668
2755
  );
2669
2756
  }
2670
2757
 
2671
- for (const [property, config] of Object.entries(tween)) {
2672
- if (!AUDIO_EFFECT_TWEEN_PROPERTY_KEYS.includes(property)) {
2758
+ for (const [property, config] of Object.entries(tracks)) {
2759
+ if (!AUDIO_EFFECT_PROPERTY_KEYS.includes(property)) {
2673
2760
  return invalidFromErrorFactory(
2674
2761
  errorFactory,
2675
- `${path}.${property} is not a supported audio effect tween property`,
2762
+ `${path}.${property} is not a supported audio effect property`,
2676
2763
  );
2677
2764
  }
2678
2765
 
@@ -2721,141 +2808,6 @@ const validateAudioEffectTween = ({ tween, path, errorFactory }) => {
2721
2808
  }
2722
2809
  };
2723
2810
 
2724
- const validateAudioEffectFade = ({ fade, path, side, errorFactory }) => {
2725
- if (!isPlainObject(fade)) {
2726
- return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
2727
- }
2728
-
2729
- if (Object.hasOwn(fade, "keyframes")) {
2730
- {
2731
- const result = validateAllowedKeys({
2732
- value: fade,
2733
- allowedKeys: ["initialValue", "keyframes"],
2734
- path,
2735
- errorFactory,
2736
- });
2737
- if (result?.valid === false) {
2738
- return result;
2739
- }
2740
- }
2741
-
2742
- {
2743
- const result = validateAudioEffectInitialValue({
2744
- value: fade.initialValue,
2745
- property: "volume",
2746
- path: `${path}.initialValue`,
2747
- errorFactory,
2748
- });
2749
- if (result?.valid === false) {
2750
- return result;
2751
- }
2752
- }
2753
-
2754
- {
2755
- const result = validateAudioEffectKeyframes({
2756
- keyframes: fade.keyframes,
2757
- property: "volume",
2758
- path: `${path}.keyframes`,
2759
- errorFactory,
2760
- });
2761
- if (result?.valid === false) {
2762
- return result;
2763
- }
2764
- }
2765
-
2766
- for (const [index, keyframe] of fade.keyframes.entries()) {
2767
- if (keyframe.relative === true) {
2768
- return invalidFromErrorFactory(
2769
- errorFactory,
2770
- `${path}.keyframes[${index}].relative is not supported for transition fades`,
2771
- );
2772
- }
2773
- }
2774
-
2775
- return;
2776
- }
2777
-
2778
- {
2779
- const result = validateAllowedKeys({
2780
- value: fade,
2781
- allowedKeys: ["initialValue", "delay", "duration", "easing"],
2782
- path,
2783
- errorFactory,
2784
- });
2785
- if (result?.valid === false) {
2786
- return result;
2787
- }
2788
- }
2789
-
2790
- {
2791
- const result = validateAudioEffectInitialValue({
2792
- value: fade.initialValue,
2793
- property: "volume",
2794
- path: `${path}.initialValue`,
2795
- errorFactory,
2796
- });
2797
- if (result?.valid === false) {
2798
- return result;
2799
- }
2800
- }
2801
-
2802
- if (!Object.hasOwn(fade, "duration")) {
2803
- return invalidFromErrorFactory(
2804
- errorFactory,
2805
- `${path}.duration is required`,
2806
- );
2807
- }
2808
-
2809
- if (!isFiniteNumber(fade.duration) || fade.duration < 0) {
2810
- return invalidFromErrorFactory(
2811
- errorFactory,
2812
- `${path}.duration must be a finite number >= 0`,
2813
- );
2814
- }
2815
-
2816
- if (
2817
- fade.delay !== undefined &&
2818
- (!isFiniteNumber(fade.delay) || fade.delay < 0)
2819
- ) {
2820
- return invalidFromErrorFactory(
2821
- errorFactory,
2822
- `${path}.delay must be a finite number >= 0 when provided`,
2823
- );
2824
- }
2825
-
2826
- return validateAudioEffectEasing({
2827
- value: fade.easing,
2828
- path: `${path}.easing`,
2829
- errorFactory,
2830
- });
2831
- };
2832
-
2833
- const validateAudioEffectTransitionSide = ({
2834
- side,
2835
- sideName,
2836
- path,
2837
- errorFactory,
2838
- }) => {
2839
- {
2840
- const result = validateExactKeys({
2841
- value: side,
2842
- expectedKeys: ["fade"],
2843
- path,
2844
- errorFactory,
2845
- });
2846
- if (result?.valid === false) {
2847
- return result;
2848
- }
2849
- }
2850
-
2851
- return validateAudioEffectFade({
2852
- fade: side.fade,
2853
- path: `${path}.fade`,
2854
- side: sideName,
2855
- errorFactory,
2856
- });
2857
- };
2858
-
2859
2811
  const validateAudioEffectDefinition = ({ audioEffect, path, errorFactory }) => {
2860
2812
  {
2861
2813
  const result = validateAllowedKeys({
@@ -2891,8 +2843,8 @@ const validateAudioEffectDefinition = ({ audioEffect, path, errorFactory }) => {
2891
2843
  );
2892
2844
  }
2893
2845
 
2894
- return validateAudioEffectTween({
2895
- tween: audioEffect.tween,
2846
+ return validateAudioEffectPropertyTracks({
2847
+ tracks: audioEffect.tween,
2896
2848
  path: `${path}.tween`,
2897
2849
  errorFactory,
2898
2850
  });
@@ -2917,9 +2869,8 @@ const validateAudioEffectDefinition = ({ audioEffect, path, errorFactory }) => {
2917
2869
  continue;
2918
2870
  }
2919
2871
 
2920
- const result = validateAudioEffectTransitionSide({
2921
- side: audioEffect[side],
2922
- sideName: side,
2872
+ const result = validateAudioEffectPropertyTracks({
2873
+ tracks: audioEffect[side],
2923
2874
  path: `${path}.${side}`,
2924
2875
  errorFactory,
2925
2876
  });
@@ -12396,6 +12347,7 @@ const validateAnimationCreateData = ({ data, errorFactory }) => {
12396
12347
  "tagIds",
12397
12348
  "thumbnailFileId",
12398
12349
  "preview",
12350
+ "cameraTracks",
12399
12351
  "animation",
12400
12352
  ],
12401
12353
  path: "payload.data",
@@ -12463,6 +12415,12 @@ const validateAnimationCreateData = ({ data, errorFactory }) => {
12463
12415
  return result;
12464
12416
  }
12465
12417
  }
12418
+ return validateCameraTracks({
12419
+ value: data.cameraTracks,
12420
+ animation: data.animation,
12421
+ path: "payload.data.cameraTracks",
12422
+ errorFactory,
12423
+ });
12466
12424
  }
12467
12425
  };
12468
12426
 
@@ -12476,6 +12434,7 @@ const validateAnimationUpdateData = ({ data, errorFactory }) => {
12476
12434
  "tagIds",
12477
12435
  "thumbnailFileId",
12478
12436
  "preview",
12437
+ "cameraTracks",
12479
12438
  "animation",
12480
12439
  ],
12481
12440
  path: "payload.data",
@@ -12551,6 +12510,12 @@ const validateAnimationUpdateData = ({ data, errorFactory }) => {
12551
12510
  }
12552
12511
  }
12553
12512
  }
12513
+ return validateCameraTracks({
12514
+ value: data.cameraTracks,
12515
+ animation: data.animation,
12516
+ path: "payload.data.cameraTracks",
12517
+ errorFactory,
12518
+ });
12554
12519
  };
12555
12520
 
12556
12521
  const validateAudioEffectCreateData = ({ data, errorFactory }) => {
@@ -19202,6 +19167,9 @@ const COMMAND_DEFINITIONS = [
19202
19167
  if (payload.data.preview !== undefined) {
19203
19168
  nextAnimation.preview = structuredClone(payload.data.preview);
19204
19169
  }
19170
+ if (payload.data.cameraTracks !== undefined) {
19171
+ nextAnimation.cameraTracks = [...payload.data.cameraTracks];
19172
+ }
19205
19173
  nextAnimation.animation = structuredClone(payload.data.animation);
19206
19174
  }
19207
19175
 
@@ -19271,6 +19239,13 @@ const COMMAND_DEFINITIONS = [
19271
19239
  }
19272
19240
 
19273
19241
  if (currentAnimation.type === "animation") {
19242
+ const cameraResult = validateCameraTracks({
19243
+ value: payload.data.cameraTracks ?? currentAnimation.cameraTracks,
19244
+ animation: payload.data.animation ?? currentAnimation.animation,
19245
+ path: "payload.data.cameraTracks",
19246
+ errorFactory: createPreconditionValidationError,
19247
+ });
19248
+ if (cameraResult?.valid === false) return cameraResult;
19274
19249
  if (payload.data.animation !== undefined) {
19275
19250
  const result = validateAnimationMaskImageReferences({
19276
19251
  state,