@routevn/creator-model 1.8.0 → 1.9.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/model.js +881 -81
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/model.js CHANGED
@@ -23,6 +23,7 @@ const COLLECTION_KEYS = [
23
23
  "images",
24
24
  "spritesheets",
25
25
  "sounds",
26
+ "voices",
26
27
  "videos",
27
28
  "animations",
28
29
  "particles",
@@ -245,6 +246,7 @@ const normalizeStateCollections = (state) => {
245
246
  "spritesheets",
246
247
  "particles",
247
248
  "controls",
249
+ "voices",
248
250
  ].filter((key) => state[key] === undefined);
249
251
  const normalizedTags = normalizeTagsState(state.tags);
250
252
  const hasNormalizedTags = normalizedTags !== state.tags;
@@ -392,7 +394,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
392
394
  "container-ref-confirm-dialog-ok",
393
395
  "container-ref-confirm-dialog-cancel",
394
396
  ];
395
- export const SCHEMA_VERSION = 8;
397
+ export const SCHEMA_VERSION = 9;
396
398
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
397
399
  "folder",
398
400
  "container",
@@ -1443,6 +1445,105 @@ const validateSoundItems = ({ items, path, errorFactory }) => {
1443
1445
  }
1444
1446
  };
1445
1447
 
1448
+ const validateVoiceItems = ({ items, path, errorFactory }) => {
1449
+ for (const [itemId, item] of Object.entries(items)) {
1450
+ const itemPath = `${path}.${itemId}`;
1451
+
1452
+ if (item?.type !== "folder" && item?.type !== "voice") {
1453
+ return invalidFromErrorFactory(
1454
+ errorFactory,
1455
+ `${itemPath}.type must be 'folder' or 'voice'`,
1456
+ );
1457
+ }
1458
+
1459
+ {
1460
+ const result = validateAllowedKeys({
1461
+ value: item,
1462
+ allowedKeys:
1463
+ item.type === "folder"
1464
+ ? ["id", "type", "name", "description"]
1465
+ : [
1466
+ "id",
1467
+ "type",
1468
+ "name",
1469
+ "description",
1470
+ "sceneId",
1471
+ "fileId",
1472
+ "waveformDataFileId",
1473
+ "duration",
1474
+ ],
1475
+ path: itemPath,
1476
+ errorFactory,
1477
+ });
1478
+ if (result?.valid === false) {
1479
+ return result;
1480
+ }
1481
+ }
1482
+
1483
+ if (!isNonEmptyString(item.id)) {
1484
+ return invalidFromErrorFactory(
1485
+ errorFactory,
1486
+ `${itemPath}.id must be a non-empty string`,
1487
+ );
1488
+ }
1489
+
1490
+ if (item.id !== itemId) {
1491
+ return invalidFromErrorFactory(
1492
+ errorFactory,
1493
+ `${itemPath}.id must match item key '${itemId}'`,
1494
+ );
1495
+ }
1496
+
1497
+ if (!isNonEmptyString(item.name)) {
1498
+ return invalidFromErrorFactory(
1499
+ errorFactory,
1500
+ `${itemPath}.name must be a non-empty string`,
1501
+ );
1502
+ }
1503
+
1504
+ if (item.description !== undefined && !isString(item.description)) {
1505
+ return invalidFromErrorFactory(
1506
+ errorFactory,
1507
+ `${itemPath}.description must be a string when provided`,
1508
+ );
1509
+ }
1510
+
1511
+ if (item.type === "voice") {
1512
+ if (!isNonEmptyString(item.sceneId)) {
1513
+ return invalidFromErrorFactory(
1514
+ errorFactory,
1515
+ `${itemPath}.sceneId must be a non-empty string`,
1516
+ );
1517
+ }
1518
+
1519
+ if (!isNonEmptyString(item.fileId)) {
1520
+ return invalidFromErrorFactory(
1521
+ errorFactory,
1522
+ `${itemPath}.fileId must be a non-empty string`,
1523
+ );
1524
+ }
1525
+
1526
+ if (
1527
+ item.waveformDataFileId !== undefined &&
1528
+ item.waveformDataFileId !== null &&
1529
+ !isNonEmptyString(item.waveformDataFileId)
1530
+ ) {
1531
+ return invalidFromErrorFactory(
1532
+ errorFactory,
1533
+ `${itemPath}.waveformDataFileId must be a non-empty string or null when provided`,
1534
+ );
1535
+ }
1536
+
1537
+ if (item.duration !== undefined && !isFiniteNumber(item.duration)) {
1538
+ return invalidFromErrorFactory(
1539
+ errorFactory,
1540
+ `${itemPath}.duration must be a finite number`,
1541
+ );
1542
+ }
1543
+ }
1544
+ }
1545
+ };
1546
+
1446
1547
  const validateVideoItems = ({ items, path, errorFactory }) => {
1447
1548
  for (const [itemId, item] of Object.entries(items)) {
1448
1549
  const itemPath = `${path}.${itemId}`;
@@ -3716,6 +3817,7 @@ const validateLayoutElementData = ({
3716
3817
  "clickImageId",
3717
3818
  "hoverSoundId",
3718
3819
  "clickSoundId",
3820
+ "revealSoundId",
3719
3821
  "textStyleId",
3720
3822
  "hoverTextStyleId",
3721
3823
  "clickTextStyleId",
@@ -3943,6 +4045,7 @@ const validateLayoutElementData = ({
3943
4045
  "clickImageId",
3944
4046
  "hoverSoundId",
3945
4047
  "clickSoundId",
4048
+ "revealSoundId",
3946
4049
  "textStyleId",
3947
4050
  "hoverTextStyleId",
3948
4051
  "clickTextStyleId",
@@ -4461,6 +4564,7 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
4461
4564
  "clickImageId",
4462
4565
  "hoverSoundId",
4463
4566
  "clickSoundId",
4567
+ "revealSoundId",
4464
4568
  "textStyleId",
4465
4569
  "hoverTextStyleId",
4466
4570
  "clickTextStyleId",
@@ -6411,6 +6515,17 @@ const validateCollection = ({ collection, path }) => {
6411
6515
  return result;
6412
6516
  }
6413
6517
  }
6518
+ } else if (path === "state.voices") {
6519
+ {
6520
+ const result = validateVoiceItems({
6521
+ items: collection.items,
6522
+ path: `${path}.items`,
6523
+ errorFactory: createStateValidationError,
6524
+ });
6525
+ if (result?.valid === false) {
6526
+ return result;
6527
+ }
6528
+ }
6414
6529
  } else if (path === "state.videos") {
6415
6530
  {
6416
6531
  const result = validateVideoItems({
@@ -6596,6 +6711,18 @@ const validateCollection = ({ collection, path }) => {
6596
6711
  return result;
6597
6712
  }
6598
6713
  }
6714
+ } else if (path === "state.voices") {
6715
+ {
6716
+ const result = validateGenericFolderOwnership({
6717
+ nodes: collection.tree,
6718
+ items: collection.items,
6719
+ path: `${path}.tree`,
6720
+ folderLabel: "folder voice item",
6721
+ });
6722
+ if (result?.valid === false) {
6723
+ return result;
6724
+ }
6725
+ }
6599
6726
  } else if (path === "state.videos") {
6600
6727
  {
6601
6728
  const result = validateVideoTreeFolderOwnership({
@@ -7116,6 +7243,55 @@ export const assertInvariants = ({ state }) => {
7116
7243
  }
7117
7244
  }
7118
7245
 
7246
+ for (const [voiceId, voice] of Object.entries(state.voices.items)) {
7247
+ if (voice.type !== "voice") {
7248
+ continue;
7249
+ }
7250
+
7251
+ if (!isPlainObject(sceneItems[voice.sceneId])) {
7252
+ return invalidInvariant(
7253
+ "voice.sceneId must reference an existing scene",
7254
+ { voiceId, sceneId: voice.sceneId },
7255
+ );
7256
+ }
7257
+
7258
+ if (sceneItems[voice.sceneId].type === "folder") {
7259
+ return invalidInvariant(
7260
+ "voice.sceneId must reference a non-folder scene",
7261
+ { voiceId, sceneId: voice.sceneId },
7262
+ );
7263
+ }
7264
+
7265
+ {
7266
+ const result = validateFileReference({
7267
+ state,
7268
+ fileId: voice.fileId,
7269
+ path: "voice.fileId",
7270
+ details: { voiceId, fileId: voice.fileId },
7271
+ errorFactory: createInvariantValidationError,
7272
+ });
7273
+ if (!result.valid) {
7274
+ return result;
7275
+ }
7276
+ }
7277
+
7278
+ if (
7279
+ voice.waveformDataFileId !== undefined &&
7280
+ voice.waveformDataFileId !== null
7281
+ ) {
7282
+ const result = validateFileReference({
7283
+ state,
7284
+ fileId: voice.waveformDataFileId,
7285
+ path: "voice.waveformDataFileId",
7286
+ details: { voiceId, waveformDataFileId: voice.waveformDataFileId },
7287
+ errorFactory: createInvariantValidationError,
7288
+ });
7289
+ if (!result.valid) {
7290
+ return result;
7291
+ }
7292
+ }
7293
+ }
7294
+
7119
7295
  for (const [videoId, video] of Object.entries(state.videos.items)) {
7120
7296
  if (video.type !== "video") {
7121
7297
  continue;
@@ -7887,7 +8063,7 @@ export const assertInvariants = ({ state }) => {
7887
8063
  }
7888
8064
  }
7889
8065
 
7890
- for (const field of ["hoverSoundId", "clickSoundId"]) {
8066
+ for (const field of ["hoverSoundId", "clickSoundId", "revealSoundId"]) {
7891
8067
  if (element[field] !== undefined) {
7892
8068
  const result = assertSoundReference({
7893
8069
  ownerIdField,
@@ -9274,7 +9450,7 @@ const validateSoundUpdateData = ({ data, errorFactory }) => {
9274
9450
  }
9275
9451
  };
9276
9452
 
9277
- const validateVideoCreateData = ({ data, errorFactory }) => {
9453
+ const validateVoiceCreateData = ({ data, errorFactory }) => {
9278
9454
  if (!isPlainObject(data)) {
9279
9455
  return invalidFromErrorFactory(
9280
9456
  errorFactory,
@@ -9282,10 +9458,10 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9282
9458
  );
9283
9459
  }
9284
9460
 
9285
- if (data.type !== "folder" && data.type !== "video") {
9461
+ if (data.type !== "folder" && data.type !== "voice") {
9286
9462
  return invalidFromErrorFactory(
9287
9463
  errorFactory,
9288
- "payload.data.type must be 'folder' or 'video'",
9464
+ "payload.data.type must be 'folder' or 'voice'",
9289
9465
  );
9290
9466
  }
9291
9467
 
@@ -9299,12 +9475,10 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9299
9475
  "type",
9300
9476
  "name",
9301
9477
  "description",
9302
- "tagIds",
9478
+ "sceneId",
9303
9479
  "fileId",
9304
- "thumbnailFileId",
9480
+ "waveformDataFileId",
9305
9481
  "duration",
9306
- "width",
9307
- "height",
9308
9482
  ],
9309
9483
  path: "payload.data",
9310
9484
  errorFactory,
@@ -9328,16 +9502,12 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9328
9502
  );
9329
9503
  }
9330
9504
 
9331
- if (data.type === "video") {
9332
- {
9333
- const result = validateOptionalUniqueIdArray({
9334
- value: data.tagIds,
9335
- path: "payload.data.tagIds",
9505
+ if (data.type === "voice") {
9506
+ if (!isNonEmptyString(data.sceneId)) {
9507
+ return invalidFromErrorFactory(
9336
9508
  errorFactory,
9337
- });
9338
- if (result?.valid === false) {
9339
- return result;
9340
- }
9509
+ "payload.data.sceneId must be a non-empty string",
9510
+ );
9341
9511
  }
9342
9512
 
9343
9513
  if (!isNonEmptyString(data.fileId)) {
@@ -9347,10 +9517,14 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9347
9517
  );
9348
9518
  }
9349
9519
 
9350
- if (!isNonEmptyString(data.thumbnailFileId)) {
9520
+ if (
9521
+ data.waveformDataFileId !== undefined &&
9522
+ data.waveformDataFileId !== null &&
9523
+ !isNonEmptyString(data.waveformDataFileId)
9524
+ ) {
9351
9525
  return invalidFromErrorFactory(
9352
9526
  errorFactory,
9353
- "payload.data.thumbnailFileId must be a non-empty string",
9527
+ "payload.data.waveformDataFileId must be a non-empty string or null when provided",
9354
9528
  );
9355
9529
  }
9356
9530
 
@@ -9360,36 +9534,20 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9360
9534
  "payload.data.duration must be a finite number",
9361
9535
  );
9362
9536
  }
9363
-
9364
- if (data.width !== undefined && !isFiniteNumber(data.width)) {
9365
- return invalidFromErrorFactory(
9366
- errorFactory,
9367
- "payload.data.width must be a finite number",
9368
- );
9369
- }
9370
-
9371
- if (data.height !== undefined && !isFiniteNumber(data.height)) {
9372
- return invalidFromErrorFactory(
9373
- errorFactory,
9374
- "payload.data.height must be a finite number",
9375
- );
9376
- }
9377
9537
  }
9378
9538
  };
9379
9539
 
9380
- const validateVideoUpdateData = ({ data, errorFactory }) => {
9540
+ const validateVoiceUpdateData = ({ data, errorFactory }) => {
9381
9541
  {
9382
9542
  const result = validateAllowedKeys({
9383
9543
  value: data,
9384
9544
  allowedKeys: [
9385
9545
  "name",
9386
9546
  "description",
9387
- "tagIds",
9547
+ "sceneId",
9388
9548
  "fileId",
9389
- "thumbnailFileId",
9549
+ "waveformDataFileId",
9390
9550
  "duration",
9391
- "width",
9392
- "height",
9393
9551
  ],
9394
9552
  path: "payload.data",
9395
9553
  errorFactory,
@@ -9420,15 +9578,11 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
9420
9578
  );
9421
9579
  }
9422
9580
 
9423
- {
9424
- const result = validateOptionalUniqueIdArray({
9425
- value: data.tagIds,
9426
- path: "payload.data.tagIds",
9581
+ if (data.sceneId !== undefined && !isNonEmptyString(data.sceneId)) {
9582
+ return invalidFromErrorFactory(
9427
9583
  errorFactory,
9428
- });
9429
- if (result?.valid === false) {
9430
- return result;
9431
- }
9584
+ "payload.data.sceneId must be a non-empty string when provided",
9585
+ );
9432
9586
  }
9433
9587
 
9434
9588
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
@@ -9439,12 +9593,13 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
9439
9593
  }
9440
9594
 
9441
9595
  if (
9442
- data.thumbnailFileId !== undefined &&
9443
- !isNonEmptyString(data.thumbnailFileId)
9596
+ data.waveformDataFileId !== undefined &&
9597
+ data.waveformDataFileId !== null &&
9598
+ !isNonEmptyString(data.waveformDataFileId)
9444
9599
  ) {
9445
9600
  return invalidFromErrorFactory(
9446
9601
  errorFactory,
9447
- "payload.data.thumbnailFileId must be a non-empty string when provided",
9602
+ "payload.data.waveformDataFileId must be a non-empty string or null when provided",
9448
9603
  );
9449
9604
  }
9450
9605
 
@@ -9454,23 +9609,9 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
9454
9609
  "payload.data.duration must be a finite number",
9455
9610
  );
9456
9611
  }
9457
-
9458
- if (data.width !== undefined && !isFiniteNumber(data.width)) {
9459
- return invalidFromErrorFactory(
9460
- errorFactory,
9461
- "payload.data.width must be a finite number",
9462
- );
9463
- }
9464
-
9465
- if (data.height !== undefined && !isFiniteNumber(data.height)) {
9466
- return invalidFromErrorFactory(
9467
- errorFactory,
9468
- "payload.data.height must be a finite number",
9469
- );
9470
- }
9471
9612
  };
9472
9613
 
9473
- const validateFontCreateData = ({ data, errorFactory }) => {
9614
+ const validateVideoCreateData = ({ data, errorFactory }) => {
9474
9615
  if (!isPlainObject(data)) {
9475
9616
  return invalidFromErrorFactory(
9476
9617
  errorFactory,
@@ -9478,10 +9619,10 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9478
9619
  );
9479
9620
  }
9480
9621
 
9481
- if (data.type !== "folder" && data.type !== "font") {
9622
+ if (data.type !== "folder" && data.type !== "video") {
9482
9623
  return invalidFromErrorFactory(
9483
9624
  errorFactory,
9484
- "payload.data.type must be 'folder' or 'font'",
9625
+ "payload.data.type must be 'folder' or 'video'",
9485
9626
  );
9486
9627
  }
9487
9628
 
@@ -9491,7 +9632,17 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9491
9632
  allowedKeys:
9492
9633
  data.type === "folder"
9493
9634
  ? ["type", "name", "description"]
9494
- : ["type", "name", "description", "tagIds", "fileId", "fontFamily"],
9635
+ : [
9636
+ "type",
9637
+ "name",
9638
+ "description",
9639
+ "tagIds",
9640
+ "fileId",
9641
+ "thumbnailFileId",
9642
+ "duration",
9643
+ "width",
9644
+ "height",
9645
+ ],
9495
9646
  path: "payload.data",
9496
9647
  errorFactory,
9497
9648
  });
@@ -9514,7 +9665,7 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9514
9665
  );
9515
9666
  }
9516
9667
 
9517
- if (data.type === "font") {
9668
+ if (data.type === "video") {
9518
9669
  {
9519
9670
  const result = validateOptionalUniqueIdArray({
9520
9671
  value: data.tagIds,
@@ -9533,23 +9684,209 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9533
9684
  );
9534
9685
  }
9535
9686
 
9536
- if (!isNonEmptyString(data.fontFamily)) {
9687
+ if (!isNonEmptyString(data.thumbnailFileId)) {
9537
9688
  return invalidFromErrorFactory(
9538
9689
  errorFactory,
9539
- "payload.data.fontFamily must be a non-empty string",
9690
+ "payload.data.thumbnailFileId must be a non-empty string",
9540
9691
  );
9541
9692
  }
9542
- }
9543
- };
9544
9693
 
9545
- const validateFontUpdateData = ({ data, errorFactory }) => {
9546
- {
9547
- const result = validateAllowedKeys({
9548
- value: data,
9549
- allowedKeys: ["name", "description", "tagIds", "fileId", "fontFamily"],
9550
- path: "payload.data",
9551
- errorFactory,
9552
- });
9694
+ if (data.duration !== undefined && !isFiniteNumber(data.duration)) {
9695
+ return invalidFromErrorFactory(
9696
+ errorFactory,
9697
+ "payload.data.duration must be a finite number",
9698
+ );
9699
+ }
9700
+
9701
+ if (data.width !== undefined && !isFiniteNumber(data.width)) {
9702
+ return invalidFromErrorFactory(
9703
+ errorFactory,
9704
+ "payload.data.width must be a finite number",
9705
+ );
9706
+ }
9707
+
9708
+ if (data.height !== undefined && !isFiniteNumber(data.height)) {
9709
+ return invalidFromErrorFactory(
9710
+ errorFactory,
9711
+ "payload.data.height must be a finite number",
9712
+ );
9713
+ }
9714
+ }
9715
+ };
9716
+
9717
+ const validateVideoUpdateData = ({ data, errorFactory }) => {
9718
+ {
9719
+ const result = validateAllowedKeys({
9720
+ value: data,
9721
+ allowedKeys: [
9722
+ "name",
9723
+ "description",
9724
+ "tagIds",
9725
+ "fileId",
9726
+ "thumbnailFileId",
9727
+ "duration",
9728
+ "width",
9729
+ "height",
9730
+ ],
9731
+ path: "payload.data",
9732
+ errorFactory,
9733
+ });
9734
+ if (result?.valid === false) {
9735
+ return result;
9736
+ }
9737
+ }
9738
+
9739
+ if (Object.keys(data).length === 0) {
9740
+ return invalidFromErrorFactory(
9741
+ errorFactory,
9742
+ "payload.data must include at least one updatable field",
9743
+ );
9744
+ }
9745
+
9746
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
9747
+ return invalidFromErrorFactory(
9748
+ errorFactory,
9749
+ "payload.data.name must be a non-empty string when provided",
9750
+ );
9751
+ }
9752
+
9753
+ if (data.description !== undefined && !isString(data.description)) {
9754
+ return invalidFromErrorFactory(
9755
+ errorFactory,
9756
+ "payload.data.description must be a string when provided",
9757
+ );
9758
+ }
9759
+
9760
+ {
9761
+ const result = validateOptionalUniqueIdArray({
9762
+ value: data.tagIds,
9763
+ path: "payload.data.tagIds",
9764
+ errorFactory,
9765
+ });
9766
+ if (result?.valid === false) {
9767
+ return result;
9768
+ }
9769
+ }
9770
+
9771
+ if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
9772
+ return invalidFromErrorFactory(
9773
+ errorFactory,
9774
+ "payload.data.fileId must be a non-empty string when provided",
9775
+ );
9776
+ }
9777
+
9778
+ if (
9779
+ data.thumbnailFileId !== undefined &&
9780
+ !isNonEmptyString(data.thumbnailFileId)
9781
+ ) {
9782
+ return invalidFromErrorFactory(
9783
+ errorFactory,
9784
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
9785
+ );
9786
+ }
9787
+
9788
+ if (data.duration !== undefined && !isFiniteNumber(data.duration)) {
9789
+ return invalidFromErrorFactory(
9790
+ errorFactory,
9791
+ "payload.data.duration must be a finite number",
9792
+ );
9793
+ }
9794
+
9795
+ if (data.width !== undefined && !isFiniteNumber(data.width)) {
9796
+ return invalidFromErrorFactory(
9797
+ errorFactory,
9798
+ "payload.data.width must be a finite number",
9799
+ );
9800
+ }
9801
+
9802
+ if (data.height !== undefined && !isFiniteNumber(data.height)) {
9803
+ return invalidFromErrorFactory(
9804
+ errorFactory,
9805
+ "payload.data.height must be a finite number",
9806
+ );
9807
+ }
9808
+ };
9809
+
9810
+ const validateFontCreateData = ({ data, errorFactory }) => {
9811
+ if (!isPlainObject(data)) {
9812
+ return invalidFromErrorFactory(
9813
+ errorFactory,
9814
+ "payload.data must be an object",
9815
+ );
9816
+ }
9817
+
9818
+ if (data.type !== "folder" && data.type !== "font") {
9819
+ return invalidFromErrorFactory(
9820
+ errorFactory,
9821
+ "payload.data.type must be 'folder' or 'font'",
9822
+ );
9823
+ }
9824
+
9825
+ {
9826
+ const result = validateAllowedKeys({
9827
+ value: data,
9828
+ allowedKeys:
9829
+ data.type === "folder"
9830
+ ? ["type", "name", "description"]
9831
+ : ["type", "name", "description", "tagIds", "fileId", "fontFamily"],
9832
+ path: "payload.data",
9833
+ errorFactory,
9834
+ });
9835
+ if (result?.valid === false) {
9836
+ return result;
9837
+ }
9838
+ }
9839
+
9840
+ if (!isNonEmptyString(data.name)) {
9841
+ return invalidFromErrorFactory(
9842
+ errorFactory,
9843
+ "payload.data.name must be a non-empty string",
9844
+ );
9845
+ }
9846
+
9847
+ if (data.description !== undefined && !isString(data.description)) {
9848
+ return invalidFromErrorFactory(
9849
+ errorFactory,
9850
+ "payload.data.description must be a string when provided",
9851
+ );
9852
+ }
9853
+
9854
+ if (data.type === "font") {
9855
+ {
9856
+ const result = validateOptionalUniqueIdArray({
9857
+ value: data.tagIds,
9858
+ path: "payload.data.tagIds",
9859
+ errorFactory,
9860
+ });
9861
+ if (result?.valid === false) {
9862
+ return result;
9863
+ }
9864
+ }
9865
+
9866
+ if (!isNonEmptyString(data.fileId)) {
9867
+ return invalidFromErrorFactory(
9868
+ errorFactory,
9869
+ "payload.data.fileId must be a non-empty string",
9870
+ );
9871
+ }
9872
+
9873
+ if (!isNonEmptyString(data.fontFamily)) {
9874
+ return invalidFromErrorFactory(
9875
+ errorFactory,
9876
+ "payload.data.fontFamily must be a non-empty string",
9877
+ );
9878
+ }
9879
+ }
9880
+ };
9881
+
9882
+ const validateFontUpdateData = ({ data, errorFactory }) => {
9883
+ {
9884
+ const result = validateAllowedKeys({
9885
+ value: data,
9886
+ allowedKeys: ["name", "description", "tagIds", "fileId", "fontFamily"],
9887
+ path: "payload.data",
9888
+ errorFactory,
9889
+ });
9553
9890
  if (result?.valid === false) {
9554
9891
  return result;
9555
9892
  }
@@ -11967,6 +12304,22 @@ const validateVisualElementReferenceTargets = ({
11967
12304
  }
11968
12305
  }
11969
12306
 
12307
+ if (data.revealSoundId !== undefined) {
12308
+ const sound = state.sounds.items[data.revealSoundId];
12309
+ if (!isPlainObject(sound) || sound.type === "folder") {
12310
+ return invalidFromErrorFactory(
12311
+ errorFactory,
12312
+ `${ownerLabel} element revealSoundId must reference an existing non-folder sound`,
12313
+ {
12314
+ [ownerIdField]: ownerId,
12315
+ elementId,
12316
+ field: "revealSoundId",
12317
+ targetId: data.revealSoundId,
12318
+ },
12319
+ );
12320
+ }
12321
+ }
12322
+
11970
12323
  if (data.thumbImageId !== undefined) {
11971
12324
  const image = state.images.items[data.thumbImageId];
11972
12325
  if (!isPlainObject(image) || image.type === "folder") {
@@ -12854,6 +13207,28 @@ const findReferencedFileUsage = ({ state, fileId }) => {
12854
13207
  }
12855
13208
  }
12856
13209
 
13210
+ for (const [voiceId, voice] of Object.entries(state.voices.items)) {
13211
+ if (voice.type !== "voice") {
13212
+ continue;
13213
+ }
13214
+
13215
+ if (voice.fileId === fileId) {
13216
+ return {
13217
+ kind: "voice",
13218
+ field: "fileId",
13219
+ ownerId: voiceId,
13220
+ };
13221
+ }
13222
+
13223
+ if (voice.waveformDataFileId === fileId) {
13224
+ return {
13225
+ kind: "voice",
13226
+ field: "waveformDataFileId",
13227
+ ownerId: voiceId,
13228
+ };
13229
+ }
13230
+ }
13231
+
12857
13232
  for (const [videoId, video] of Object.entries(state.videos.items)) {
12858
13233
  if (video.type !== "video") {
12859
13234
  continue;
@@ -15384,6 +15759,431 @@ const COMMAND_DEFINITIONS = [
15384
15759
  return state;
15385
15760
  },
15386
15761
  },
15762
+ {
15763
+ type: "voice.create",
15764
+ validatePayload: ({ payload }) => {
15765
+ {
15766
+ const result = validateAllowedKeys({
15767
+ value: payload,
15768
+ allowedKeys: [
15769
+ "voiceId",
15770
+ "parentId",
15771
+ "data",
15772
+ "index",
15773
+ "position",
15774
+ "positionTargetId",
15775
+ ],
15776
+ path: "payload",
15777
+ errorFactory: createPayloadValidationError,
15778
+ });
15779
+ if (result?.valid === false) {
15780
+ return result;
15781
+ }
15782
+ }
15783
+
15784
+ if (!isNonEmptyString(payload.voiceId)) {
15785
+ return invalidPayload("payload.voiceId must be a non-empty string");
15786
+ }
15787
+
15788
+ if (
15789
+ payload.parentId !== undefined &&
15790
+ payload.parentId !== null &&
15791
+ !isNonEmptyString(payload.parentId)
15792
+ ) {
15793
+ return invalidPayload(
15794
+ "payload.parentId must be a non-empty string when provided",
15795
+ );
15796
+ }
15797
+
15798
+ {
15799
+ const result = validateVoiceCreateData({
15800
+ data: payload.data,
15801
+ errorFactory: createPayloadValidationError,
15802
+ });
15803
+ if (result?.valid === false) {
15804
+ return result;
15805
+ }
15806
+ }
15807
+
15808
+ {
15809
+ const result = validatePlacementFields({
15810
+ payload,
15811
+ errorFactory: createPayloadValidationError,
15812
+ });
15813
+ if (result?.valid === false) {
15814
+ return result;
15815
+ }
15816
+ }
15817
+ },
15818
+ validateAgainstState: ({ state, payload }) => {
15819
+ if (isPlainObject(state.voices.items[payload.voiceId])) {
15820
+ return invalidPrecondition("payload.voiceId must not already exist");
15821
+ }
15822
+
15823
+ const parentId = payload.parentId ?? null;
15824
+ if (parentId !== null) {
15825
+ const parentVoice = state.voices.items[parentId];
15826
+ if (!isPlainObject(parentVoice)) {
15827
+ return invalidPrecondition(
15828
+ "payload.parentId must reference an existing voice item",
15829
+ );
15830
+ }
15831
+
15832
+ if (parentVoice.type !== "folder") {
15833
+ return invalidPrecondition(
15834
+ "payload.parentId must reference a folder voice item",
15835
+ );
15836
+ }
15837
+ }
15838
+
15839
+ if (payload.positionTargetId !== undefined) {
15840
+ if (!isPlainObject(state.voices.items[payload.positionTargetId])) {
15841
+ return invalidPrecondition(
15842
+ "payload.positionTargetId must reference an existing voice item",
15843
+ );
15844
+ }
15845
+
15846
+ const targetParentId = getNodeParentId({
15847
+ tree: state.voices.tree,
15848
+ nodeId: payload.positionTargetId,
15849
+ });
15850
+
15851
+ if (targetParentId !== parentId) {
15852
+ return invalidPrecondition(
15853
+ "payload.positionTargetId must reference a sibling under payload.parentId",
15854
+ );
15855
+ }
15856
+ }
15857
+
15858
+ if (payload.data.type === "voice") {
15859
+ const scene = state.scenes.items[payload.data.sceneId];
15860
+ if (!isPlainObject(scene) || scene.type === "folder") {
15861
+ return invalidPrecondition(
15862
+ "payload.data.sceneId must reference an existing non-folder scene",
15863
+ );
15864
+ }
15865
+
15866
+ return validateReferencedFilesInData({
15867
+ state,
15868
+ data: payload.data,
15869
+ fields: ["fileId", "waveformDataFileId"],
15870
+ nullableFields: ["waveformDataFileId"],
15871
+ details: {
15872
+ voiceId: payload.voiceId,
15873
+ },
15874
+ });
15875
+ }
15876
+ },
15877
+ reduce: ({ state, payload }) => {
15878
+ const nextVoice = {
15879
+ id: payload.voiceId,
15880
+ type: payload.data.type,
15881
+ name: payload.data.name,
15882
+ };
15883
+
15884
+ if (payload.data.description !== undefined) {
15885
+ nextVoice.description = payload.data.description;
15886
+ }
15887
+
15888
+ if (payload.data.type === "voice") {
15889
+ nextVoice.sceneId = payload.data.sceneId;
15890
+ nextVoice.fileId = payload.data.fileId;
15891
+ if (payload.data.waveformDataFileId !== undefined) {
15892
+ nextVoice.waveformDataFileId = payload.data.waveformDataFileId;
15893
+ }
15894
+ if (payload.data.duration !== undefined) {
15895
+ nextVoice.duration = payload.data.duration;
15896
+ }
15897
+ }
15898
+
15899
+ state.voices.items[payload.voiceId] = nextVoice;
15900
+
15901
+ insertTreeNode({
15902
+ tree: state.voices.tree,
15903
+ node: {
15904
+ id: payload.voiceId,
15905
+ children: [],
15906
+ },
15907
+ parentId: payload.parentId ?? null,
15908
+ index: payload.index,
15909
+ position: payload.position,
15910
+ positionTargetId: payload.positionTargetId,
15911
+ });
15912
+
15913
+ return state;
15914
+ },
15915
+ },
15916
+ {
15917
+ type: "voice.update",
15918
+ validatePayload: ({ payload }) => {
15919
+ {
15920
+ const result = validateExactKeys({
15921
+ value: payload,
15922
+ expectedKeys: ["voiceId", "data"],
15923
+ path: "payload",
15924
+ errorFactory: createPayloadValidationError,
15925
+ });
15926
+ if (result?.valid === false) {
15927
+ return result;
15928
+ }
15929
+ }
15930
+
15931
+ if (!isNonEmptyString(payload.voiceId)) {
15932
+ return invalidPayload("payload.voiceId must be a non-empty string");
15933
+ }
15934
+
15935
+ {
15936
+ const result = validateVoiceUpdateData({
15937
+ data: payload.data,
15938
+ errorFactory: createPayloadValidationError,
15939
+ });
15940
+ if (result?.valid === false) {
15941
+ return result;
15942
+ }
15943
+ }
15944
+ },
15945
+ validateAgainstState: ({ state, payload }) => {
15946
+ const currentVoice = state.voices.items[payload.voiceId];
15947
+ if (!isPlainObject(currentVoice)) {
15948
+ return invalidPrecondition(
15949
+ "payload.voiceId must reference an existing voice item",
15950
+ );
15951
+ }
15952
+
15953
+ if (
15954
+ currentVoice.type === "folder" &&
15955
+ (payload.data.sceneId !== undefined ||
15956
+ payload.data.fileId !== undefined ||
15957
+ payload.data.waveformDataFileId !== undefined ||
15958
+ payload.data.duration !== undefined)
15959
+ ) {
15960
+ return invalidPrecondition(
15961
+ "folder voice items cannot update file fields",
15962
+ );
15963
+ }
15964
+
15965
+ if (currentVoice.type === "voice") {
15966
+ if (payload.data.sceneId !== undefined) {
15967
+ const scene = state.scenes.items[payload.data.sceneId];
15968
+ if (!isPlainObject(scene) || scene.type === "folder") {
15969
+ return invalidPrecondition(
15970
+ "payload.data.sceneId must reference an existing non-folder scene",
15971
+ );
15972
+ }
15973
+ }
15974
+
15975
+ return validateReferencedFilesInData({
15976
+ state,
15977
+ data: payload.data,
15978
+ fields: ["fileId", "waveformDataFileId"],
15979
+ nullableFields: ["waveformDataFileId"],
15980
+ details: {
15981
+ voiceId: payload.voiceId,
15982
+ },
15983
+ });
15984
+ }
15985
+ },
15986
+ reduce: ({ state, payload }) => {
15987
+ state.voices.items[payload.voiceId] = {
15988
+ ...structuredClone(state.voices.items[payload.voiceId]),
15989
+ ...structuredClone(payload.data),
15990
+ };
15991
+ return state;
15992
+ },
15993
+ },
15994
+ {
15995
+ type: "voice.delete",
15996
+ validatePayload: ({ payload }) => {
15997
+ {
15998
+ const result = validateExactKeys({
15999
+ value: payload,
16000
+ expectedKeys: ["voiceIds"],
16001
+ path: "payload",
16002
+ errorFactory: createPayloadValidationError,
16003
+ });
16004
+ if (result?.valid === false) {
16005
+ return result;
16006
+ }
16007
+ }
16008
+
16009
+ {
16010
+ const result = validateRequiredUniqueIdArray({
16011
+ value: payload.voiceIds,
16012
+ path: "payload.voiceIds",
16013
+ errorFactory: createPayloadValidationError,
16014
+ });
16015
+ if (result?.valid === false) {
16016
+ return result;
16017
+ }
16018
+ }
16019
+ },
16020
+ validateAgainstState: ({ state, payload }) => {
16021
+ for (const voiceId of payload.voiceIds) {
16022
+ if (!isPlainObject(state.voices.items[voiceId])) {
16023
+ return invalidPrecondition(
16024
+ "payload.voiceIds must reference existing voice items",
16025
+ { voiceId },
16026
+ );
16027
+ }
16028
+ }
16029
+ },
16030
+ reduce: ({ state, payload }) => {
16031
+ const deletedVoiceIds = new Set();
16032
+
16033
+ for (const voiceId of payload.voiceIds) {
16034
+ const removedNode = removeTreeNode({
16035
+ nodes: state.voices.tree,
16036
+ nodeId: voiceId,
16037
+ });
16038
+
16039
+ if (!removedNode) {
16040
+ continue;
16041
+ }
16042
+
16043
+ for (const id of collectTreeDescendantIds({ node: removedNode })) {
16044
+ deletedVoiceIds.add(id);
16045
+ }
16046
+ }
16047
+
16048
+ for (const voiceId of deletedVoiceIds) {
16049
+ delete state.voices.items[voiceId];
16050
+ }
16051
+
16052
+ return state;
16053
+ },
16054
+ },
16055
+ {
16056
+ type: "voice.move",
16057
+ validatePayload: ({ payload }) => {
16058
+ {
16059
+ const result = validateAllowedKeys({
16060
+ value: payload,
16061
+ allowedKeys: [
16062
+ "voiceId",
16063
+ "parentId",
16064
+ "index",
16065
+ "position",
16066
+ "positionTargetId",
16067
+ ],
16068
+ path: "payload",
16069
+ errorFactory: createPayloadValidationError,
16070
+ });
16071
+ if (result?.valid === false) {
16072
+ return result;
16073
+ }
16074
+ }
16075
+
16076
+ if (!isNonEmptyString(payload.voiceId)) {
16077
+ return invalidPayload("payload.voiceId must be a non-empty string");
16078
+ }
16079
+
16080
+ if (
16081
+ payload.parentId !== undefined &&
16082
+ payload.parentId !== null &&
16083
+ !isNonEmptyString(payload.parentId)
16084
+ ) {
16085
+ return invalidPayload(
16086
+ "payload.parentId must be a non-empty string when provided",
16087
+ );
16088
+ }
16089
+
16090
+ {
16091
+ const result = validatePlacementFields({
16092
+ payload,
16093
+ errorFactory: createPayloadValidationError,
16094
+ });
16095
+ if (result?.valid === false) {
16096
+ return result;
16097
+ }
16098
+ }
16099
+ },
16100
+ validateAgainstState: ({ state, payload }) => {
16101
+ const voice = state.voices.items[payload.voiceId];
16102
+ if (!isPlainObject(voice)) {
16103
+ return invalidPrecondition(
16104
+ "payload.voiceId must reference an existing voice item",
16105
+ );
16106
+ }
16107
+
16108
+ const voiceNode = findTreeNode({
16109
+ nodes: state.voices.tree,
16110
+ nodeId: payload.voiceId,
16111
+ });
16112
+
16113
+ if (payload.parentId !== undefined && payload.parentId !== null) {
16114
+ const parentVoice = state.voices.items[payload.parentId];
16115
+ if (!isPlainObject(parentVoice)) {
16116
+ return invalidPrecondition(
16117
+ "payload.parentId must reference an existing voice item",
16118
+ );
16119
+ }
16120
+
16121
+ if (parentVoice.type !== "folder") {
16122
+ return invalidPrecondition(
16123
+ "payload.parentId must reference a folder voice item",
16124
+ );
16125
+ }
16126
+
16127
+ const descendantIds = new Set(
16128
+ collectTreeDescendantIds({
16129
+ node: voiceNode,
16130
+ }),
16131
+ );
16132
+
16133
+ if (descendantIds.has(payload.parentId)) {
16134
+ return invalidPrecondition(
16135
+ "payload.parentId must not target the moved voice item or its descendants",
16136
+ );
16137
+ }
16138
+ }
16139
+
16140
+ if (payload.positionTargetId !== undefined) {
16141
+ if (payload.positionTargetId === payload.voiceId) {
16142
+ return invalidPrecondition(
16143
+ "payload.positionTargetId must not reference the moved voice item",
16144
+ );
16145
+ }
16146
+
16147
+ if (!isPlainObject(state.voices.items[payload.positionTargetId])) {
16148
+ return invalidPrecondition(
16149
+ "payload.positionTargetId must reference an existing voice item",
16150
+ );
16151
+ }
16152
+
16153
+ const targetParentId = getNodeParentId({
16154
+ tree: state.voices.tree,
16155
+ nodeId: payload.positionTargetId,
16156
+ });
16157
+
16158
+ if (targetParentId !== (payload.parentId ?? null)) {
16159
+ return invalidPrecondition(
16160
+ "payload.positionTargetId must reference a sibling under payload.parentId",
16161
+ );
16162
+ }
16163
+ }
16164
+ },
16165
+ reduce: ({ state, payload }) => {
16166
+ const voiceNodeResult = removeNodeOrResult({
16167
+ tree: state.voices.tree,
16168
+ nodeId: payload.voiceId,
16169
+ errorMessage: "voice move target missing from tree",
16170
+ });
16171
+ if (!voiceNodeResult.valid) {
16172
+ return voiceNodeResult;
16173
+ }
16174
+
16175
+ insertTreeNode({
16176
+ tree: state.voices.tree,
16177
+ node: voiceNodeResult.node,
16178
+ parentId: payload.parentId ?? null,
16179
+ index: payload.index,
16180
+ position: payload.position,
16181
+ positionTargetId: payload.positionTargetId,
16182
+ });
16183
+
16184
+ return state;
16185
+ },
16186
+ },
15387
16187
  {
15388
16188
  type: "video.create",
15389
16189
  validatePayload: ({ payload }) => {