@routevn/creator-model 1.8.0 → 1.8.1

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 +860 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
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;
@@ -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}`;
@@ -6411,6 +6512,17 @@ const validateCollection = ({ collection, path }) => {
6411
6512
  return result;
6412
6513
  }
6413
6514
  }
6515
+ } else if (path === "state.voices") {
6516
+ {
6517
+ const result = validateVoiceItems({
6518
+ items: collection.items,
6519
+ path: `${path}.items`,
6520
+ errorFactory: createStateValidationError,
6521
+ });
6522
+ if (result?.valid === false) {
6523
+ return result;
6524
+ }
6525
+ }
6414
6526
  } else if (path === "state.videos") {
6415
6527
  {
6416
6528
  const result = validateVideoItems({
@@ -6596,6 +6708,18 @@ const validateCollection = ({ collection, path }) => {
6596
6708
  return result;
6597
6709
  }
6598
6710
  }
6711
+ } else if (path === "state.voices") {
6712
+ {
6713
+ const result = validateGenericFolderOwnership({
6714
+ nodes: collection.tree,
6715
+ items: collection.items,
6716
+ path: `${path}.tree`,
6717
+ folderLabel: "folder voice item",
6718
+ });
6719
+ if (result?.valid === false) {
6720
+ return result;
6721
+ }
6722
+ }
6599
6723
  } else if (path === "state.videos") {
6600
6724
  {
6601
6725
  const result = validateVideoTreeFolderOwnership({
@@ -7116,6 +7240,55 @@ export const assertInvariants = ({ state }) => {
7116
7240
  }
7117
7241
  }
7118
7242
 
7243
+ for (const [voiceId, voice] of Object.entries(state.voices.items)) {
7244
+ if (voice.type !== "voice") {
7245
+ continue;
7246
+ }
7247
+
7248
+ if (!isPlainObject(sceneItems[voice.sceneId])) {
7249
+ return invalidInvariant(
7250
+ "voice.sceneId must reference an existing scene",
7251
+ { voiceId, sceneId: voice.sceneId },
7252
+ );
7253
+ }
7254
+
7255
+ if (sceneItems[voice.sceneId].type === "folder") {
7256
+ return invalidInvariant(
7257
+ "voice.sceneId must reference a non-folder scene",
7258
+ { voiceId, sceneId: voice.sceneId },
7259
+ );
7260
+ }
7261
+
7262
+ {
7263
+ const result = validateFileReference({
7264
+ state,
7265
+ fileId: voice.fileId,
7266
+ path: "voice.fileId",
7267
+ details: { voiceId, fileId: voice.fileId },
7268
+ errorFactory: createInvariantValidationError,
7269
+ });
7270
+ if (!result.valid) {
7271
+ return result;
7272
+ }
7273
+ }
7274
+
7275
+ if (
7276
+ voice.waveformDataFileId !== undefined &&
7277
+ voice.waveformDataFileId !== null
7278
+ ) {
7279
+ const result = validateFileReference({
7280
+ state,
7281
+ fileId: voice.waveformDataFileId,
7282
+ path: "voice.waveformDataFileId",
7283
+ details: { voiceId, waveformDataFileId: voice.waveformDataFileId },
7284
+ errorFactory: createInvariantValidationError,
7285
+ });
7286
+ if (!result.valid) {
7287
+ return result;
7288
+ }
7289
+ }
7290
+ }
7291
+
7119
7292
  for (const [videoId, video] of Object.entries(state.videos.items)) {
7120
7293
  if (video.type !== "video") {
7121
7294
  continue;
@@ -9274,7 +9447,7 @@ const validateSoundUpdateData = ({ data, errorFactory }) => {
9274
9447
  }
9275
9448
  };
9276
9449
 
9277
- const validateVideoCreateData = ({ data, errorFactory }) => {
9450
+ const validateVoiceCreateData = ({ data, errorFactory }) => {
9278
9451
  if (!isPlainObject(data)) {
9279
9452
  return invalidFromErrorFactory(
9280
9453
  errorFactory,
@@ -9282,10 +9455,10 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9282
9455
  );
9283
9456
  }
9284
9457
 
9285
- if (data.type !== "folder" && data.type !== "video") {
9458
+ if (data.type !== "folder" && data.type !== "voice") {
9286
9459
  return invalidFromErrorFactory(
9287
9460
  errorFactory,
9288
- "payload.data.type must be 'folder' or 'video'",
9461
+ "payload.data.type must be 'folder' or 'voice'",
9289
9462
  );
9290
9463
  }
9291
9464
 
@@ -9299,12 +9472,10 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9299
9472
  "type",
9300
9473
  "name",
9301
9474
  "description",
9302
- "tagIds",
9475
+ "sceneId",
9303
9476
  "fileId",
9304
- "thumbnailFileId",
9477
+ "waveformDataFileId",
9305
9478
  "duration",
9306
- "width",
9307
- "height",
9308
9479
  ],
9309
9480
  path: "payload.data",
9310
9481
  errorFactory,
@@ -9328,16 +9499,12 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9328
9499
  );
9329
9500
  }
9330
9501
 
9331
- if (data.type === "video") {
9332
- {
9333
- const result = validateOptionalUniqueIdArray({
9334
- value: data.tagIds,
9335
- path: "payload.data.tagIds",
9502
+ if (data.type === "voice") {
9503
+ if (!isNonEmptyString(data.sceneId)) {
9504
+ return invalidFromErrorFactory(
9336
9505
  errorFactory,
9337
- });
9338
- if (result?.valid === false) {
9339
- return result;
9340
- }
9506
+ "payload.data.sceneId must be a non-empty string",
9507
+ );
9341
9508
  }
9342
9509
 
9343
9510
  if (!isNonEmptyString(data.fileId)) {
@@ -9347,10 +9514,14 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9347
9514
  );
9348
9515
  }
9349
9516
 
9350
- if (!isNonEmptyString(data.thumbnailFileId)) {
9517
+ if (
9518
+ data.waveformDataFileId !== undefined &&
9519
+ data.waveformDataFileId !== null &&
9520
+ !isNonEmptyString(data.waveformDataFileId)
9521
+ ) {
9351
9522
  return invalidFromErrorFactory(
9352
9523
  errorFactory,
9353
- "payload.data.thumbnailFileId must be a non-empty string",
9524
+ "payload.data.waveformDataFileId must be a non-empty string or null when provided",
9354
9525
  );
9355
9526
  }
9356
9527
 
@@ -9360,36 +9531,20 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
9360
9531
  "payload.data.duration must be a finite number",
9361
9532
  );
9362
9533
  }
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
9534
  }
9378
9535
  };
9379
9536
 
9380
- const validateVideoUpdateData = ({ data, errorFactory }) => {
9537
+ const validateVoiceUpdateData = ({ data, errorFactory }) => {
9381
9538
  {
9382
9539
  const result = validateAllowedKeys({
9383
9540
  value: data,
9384
9541
  allowedKeys: [
9385
9542
  "name",
9386
9543
  "description",
9387
- "tagIds",
9544
+ "sceneId",
9388
9545
  "fileId",
9389
- "thumbnailFileId",
9546
+ "waveformDataFileId",
9390
9547
  "duration",
9391
- "width",
9392
- "height",
9393
9548
  ],
9394
9549
  path: "payload.data",
9395
9550
  errorFactory,
@@ -9420,15 +9575,11 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
9420
9575
  );
9421
9576
  }
9422
9577
 
9423
- {
9424
- const result = validateOptionalUniqueIdArray({
9425
- value: data.tagIds,
9426
- path: "payload.data.tagIds",
9578
+ if (data.sceneId !== undefined && !isNonEmptyString(data.sceneId)) {
9579
+ return invalidFromErrorFactory(
9427
9580
  errorFactory,
9428
- });
9429
- if (result?.valid === false) {
9430
- return result;
9431
- }
9581
+ "payload.data.sceneId must be a non-empty string when provided",
9582
+ );
9432
9583
  }
9433
9584
 
9434
9585
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
@@ -9439,12 +9590,13 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
9439
9590
  }
9440
9591
 
9441
9592
  if (
9442
- data.thumbnailFileId !== undefined &&
9443
- !isNonEmptyString(data.thumbnailFileId)
9593
+ data.waveformDataFileId !== undefined &&
9594
+ data.waveformDataFileId !== null &&
9595
+ !isNonEmptyString(data.waveformDataFileId)
9444
9596
  ) {
9445
9597
  return invalidFromErrorFactory(
9446
9598
  errorFactory,
9447
- "payload.data.thumbnailFileId must be a non-empty string when provided",
9599
+ "payload.data.waveformDataFileId must be a non-empty string or null when provided",
9448
9600
  );
9449
9601
  }
9450
9602
 
@@ -9454,23 +9606,9 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
9454
9606
  "payload.data.duration must be a finite number",
9455
9607
  );
9456
9608
  }
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
9609
  };
9472
9610
 
9473
- const validateFontCreateData = ({ data, errorFactory }) => {
9611
+ const validateVideoCreateData = ({ data, errorFactory }) => {
9474
9612
  if (!isPlainObject(data)) {
9475
9613
  return invalidFromErrorFactory(
9476
9614
  errorFactory,
@@ -9478,10 +9616,10 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9478
9616
  );
9479
9617
  }
9480
9618
 
9481
- if (data.type !== "folder" && data.type !== "font") {
9619
+ if (data.type !== "folder" && data.type !== "video") {
9482
9620
  return invalidFromErrorFactory(
9483
9621
  errorFactory,
9484
- "payload.data.type must be 'folder' or 'font'",
9622
+ "payload.data.type must be 'folder' or 'video'",
9485
9623
  );
9486
9624
  }
9487
9625
 
@@ -9491,7 +9629,17 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9491
9629
  allowedKeys:
9492
9630
  data.type === "folder"
9493
9631
  ? ["type", "name", "description"]
9494
- : ["type", "name", "description", "tagIds", "fileId", "fontFamily"],
9632
+ : [
9633
+ "type",
9634
+ "name",
9635
+ "description",
9636
+ "tagIds",
9637
+ "fileId",
9638
+ "thumbnailFileId",
9639
+ "duration",
9640
+ "width",
9641
+ "height",
9642
+ ],
9495
9643
  path: "payload.data",
9496
9644
  errorFactory,
9497
9645
  });
@@ -9514,7 +9662,7 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9514
9662
  );
9515
9663
  }
9516
9664
 
9517
- if (data.type === "font") {
9665
+ if (data.type === "video") {
9518
9666
  {
9519
9667
  const result = validateOptionalUniqueIdArray({
9520
9668
  value: data.tagIds,
@@ -9533,23 +9681,209 @@ const validateFontCreateData = ({ data, errorFactory }) => {
9533
9681
  );
9534
9682
  }
9535
9683
 
9536
- if (!isNonEmptyString(data.fontFamily)) {
9684
+ if (!isNonEmptyString(data.thumbnailFileId)) {
9537
9685
  return invalidFromErrorFactory(
9538
9686
  errorFactory,
9539
- "payload.data.fontFamily must be a non-empty string",
9687
+ "payload.data.thumbnailFileId must be a non-empty string",
9540
9688
  );
9541
9689
  }
9542
- }
9543
- };
9544
9690
 
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
- });
9691
+ if (data.duration !== undefined && !isFiniteNumber(data.duration)) {
9692
+ return invalidFromErrorFactory(
9693
+ errorFactory,
9694
+ "payload.data.duration must be a finite number",
9695
+ );
9696
+ }
9697
+
9698
+ if (data.width !== undefined && !isFiniteNumber(data.width)) {
9699
+ return invalidFromErrorFactory(
9700
+ errorFactory,
9701
+ "payload.data.width must be a finite number",
9702
+ );
9703
+ }
9704
+
9705
+ if (data.height !== undefined && !isFiniteNumber(data.height)) {
9706
+ return invalidFromErrorFactory(
9707
+ errorFactory,
9708
+ "payload.data.height must be a finite number",
9709
+ );
9710
+ }
9711
+ }
9712
+ };
9713
+
9714
+ const validateVideoUpdateData = ({ data, errorFactory }) => {
9715
+ {
9716
+ const result = validateAllowedKeys({
9717
+ value: data,
9718
+ allowedKeys: [
9719
+ "name",
9720
+ "description",
9721
+ "tagIds",
9722
+ "fileId",
9723
+ "thumbnailFileId",
9724
+ "duration",
9725
+ "width",
9726
+ "height",
9727
+ ],
9728
+ path: "payload.data",
9729
+ errorFactory,
9730
+ });
9731
+ if (result?.valid === false) {
9732
+ return result;
9733
+ }
9734
+ }
9735
+
9736
+ if (Object.keys(data).length === 0) {
9737
+ return invalidFromErrorFactory(
9738
+ errorFactory,
9739
+ "payload.data must include at least one updatable field",
9740
+ );
9741
+ }
9742
+
9743
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
9744
+ return invalidFromErrorFactory(
9745
+ errorFactory,
9746
+ "payload.data.name must be a non-empty string when provided",
9747
+ );
9748
+ }
9749
+
9750
+ if (data.description !== undefined && !isString(data.description)) {
9751
+ return invalidFromErrorFactory(
9752
+ errorFactory,
9753
+ "payload.data.description must be a string when provided",
9754
+ );
9755
+ }
9756
+
9757
+ {
9758
+ const result = validateOptionalUniqueIdArray({
9759
+ value: data.tagIds,
9760
+ path: "payload.data.tagIds",
9761
+ errorFactory,
9762
+ });
9763
+ if (result?.valid === false) {
9764
+ return result;
9765
+ }
9766
+ }
9767
+
9768
+ if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
9769
+ return invalidFromErrorFactory(
9770
+ errorFactory,
9771
+ "payload.data.fileId must be a non-empty string when provided",
9772
+ );
9773
+ }
9774
+
9775
+ if (
9776
+ data.thumbnailFileId !== undefined &&
9777
+ !isNonEmptyString(data.thumbnailFileId)
9778
+ ) {
9779
+ return invalidFromErrorFactory(
9780
+ errorFactory,
9781
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
9782
+ );
9783
+ }
9784
+
9785
+ if (data.duration !== undefined && !isFiniteNumber(data.duration)) {
9786
+ return invalidFromErrorFactory(
9787
+ errorFactory,
9788
+ "payload.data.duration must be a finite number",
9789
+ );
9790
+ }
9791
+
9792
+ if (data.width !== undefined && !isFiniteNumber(data.width)) {
9793
+ return invalidFromErrorFactory(
9794
+ errorFactory,
9795
+ "payload.data.width must be a finite number",
9796
+ );
9797
+ }
9798
+
9799
+ if (data.height !== undefined && !isFiniteNumber(data.height)) {
9800
+ return invalidFromErrorFactory(
9801
+ errorFactory,
9802
+ "payload.data.height must be a finite number",
9803
+ );
9804
+ }
9805
+ };
9806
+
9807
+ const validateFontCreateData = ({ data, errorFactory }) => {
9808
+ if (!isPlainObject(data)) {
9809
+ return invalidFromErrorFactory(
9810
+ errorFactory,
9811
+ "payload.data must be an object",
9812
+ );
9813
+ }
9814
+
9815
+ if (data.type !== "folder" && data.type !== "font") {
9816
+ return invalidFromErrorFactory(
9817
+ errorFactory,
9818
+ "payload.data.type must be 'folder' or 'font'",
9819
+ );
9820
+ }
9821
+
9822
+ {
9823
+ const result = validateAllowedKeys({
9824
+ value: data,
9825
+ allowedKeys:
9826
+ data.type === "folder"
9827
+ ? ["type", "name", "description"]
9828
+ : ["type", "name", "description", "tagIds", "fileId", "fontFamily"],
9829
+ path: "payload.data",
9830
+ errorFactory,
9831
+ });
9832
+ if (result?.valid === false) {
9833
+ return result;
9834
+ }
9835
+ }
9836
+
9837
+ if (!isNonEmptyString(data.name)) {
9838
+ return invalidFromErrorFactory(
9839
+ errorFactory,
9840
+ "payload.data.name must be a non-empty string",
9841
+ );
9842
+ }
9843
+
9844
+ if (data.description !== undefined && !isString(data.description)) {
9845
+ return invalidFromErrorFactory(
9846
+ errorFactory,
9847
+ "payload.data.description must be a string when provided",
9848
+ );
9849
+ }
9850
+
9851
+ if (data.type === "font") {
9852
+ {
9853
+ const result = validateOptionalUniqueIdArray({
9854
+ value: data.tagIds,
9855
+ path: "payload.data.tagIds",
9856
+ errorFactory,
9857
+ });
9858
+ if (result?.valid === false) {
9859
+ return result;
9860
+ }
9861
+ }
9862
+
9863
+ if (!isNonEmptyString(data.fileId)) {
9864
+ return invalidFromErrorFactory(
9865
+ errorFactory,
9866
+ "payload.data.fileId must be a non-empty string",
9867
+ );
9868
+ }
9869
+
9870
+ if (!isNonEmptyString(data.fontFamily)) {
9871
+ return invalidFromErrorFactory(
9872
+ errorFactory,
9873
+ "payload.data.fontFamily must be a non-empty string",
9874
+ );
9875
+ }
9876
+ }
9877
+ };
9878
+
9879
+ const validateFontUpdateData = ({ data, errorFactory }) => {
9880
+ {
9881
+ const result = validateAllowedKeys({
9882
+ value: data,
9883
+ allowedKeys: ["name", "description", "tagIds", "fileId", "fontFamily"],
9884
+ path: "payload.data",
9885
+ errorFactory,
9886
+ });
9553
9887
  if (result?.valid === false) {
9554
9888
  return result;
9555
9889
  }
@@ -12854,6 +13188,28 @@ const findReferencedFileUsage = ({ state, fileId }) => {
12854
13188
  }
12855
13189
  }
12856
13190
 
13191
+ for (const [voiceId, voice] of Object.entries(state.voices.items)) {
13192
+ if (voice.type !== "voice") {
13193
+ continue;
13194
+ }
13195
+
13196
+ if (voice.fileId === fileId) {
13197
+ return {
13198
+ kind: "voice",
13199
+ field: "fileId",
13200
+ ownerId: voiceId,
13201
+ };
13202
+ }
13203
+
13204
+ if (voice.waveformDataFileId === fileId) {
13205
+ return {
13206
+ kind: "voice",
13207
+ field: "waveformDataFileId",
13208
+ ownerId: voiceId,
13209
+ };
13210
+ }
13211
+ }
13212
+
12857
13213
  for (const [videoId, video] of Object.entries(state.videos.items)) {
12858
13214
  if (video.type !== "video") {
12859
13215
  continue;
@@ -15384,6 +15740,431 @@ const COMMAND_DEFINITIONS = [
15384
15740
  return state;
15385
15741
  },
15386
15742
  },
15743
+ {
15744
+ type: "voice.create",
15745
+ validatePayload: ({ payload }) => {
15746
+ {
15747
+ const result = validateAllowedKeys({
15748
+ value: payload,
15749
+ allowedKeys: [
15750
+ "voiceId",
15751
+ "parentId",
15752
+ "data",
15753
+ "index",
15754
+ "position",
15755
+ "positionTargetId",
15756
+ ],
15757
+ path: "payload",
15758
+ errorFactory: createPayloadValidationError,
15759
+ });
15760
+ if (result?.valid === false) {
15761
+ return result;
15762
+ }
15763
+ }
15764
+
15765
+ if (!isNonEmptyString(payload.voiceId)) {
15766
+ return invalidPayload("payload.voiceId must be a non-empty string");
15767
+ }
15768
+
15769
+ if (
15770
+ payload.parentId !== undefined &&
15771
+ payload.parentId !== null &&
15772
+ !isNonEmptyString(payload.parentId)
15773
+ ) {
15774
+ return invalidPayload(
15775
+ "payload.parentId must be a non-empty string when provided",
15776
+ );
15777
+ }
15778
+
15779
+ {
15780
+ const result = validateVoiceCreateData({
15781
+ data: payload.data,
15782
+ errorFactory: createPayloadValidationError,
15783
+ });
15784
+ if (result?.valid === false) {
15785
+ return result;
15786
+ }
15787
+ }
15788
+
15789
+ {
15790
+ const result = validatePlacementFields({
15791
+ payload,
15792
+ errorFactory: createPayloadValidationError,
15793
+ });
15794
+ if (result?.valid === false) {
15795
+ return result;
15796
+ }
15797
+ }
15798
+ },
15799
+ validateAgainstState: ({ state, payload }) => {
15800
+ if (isPlainObject(state.voices.items[payload.voiceId])) {
15801
+ return invalidPrecondition("payload.voiceId must not already exist");
15802
+ }
15803
+
15804
+ const parentId = payload.parentId ?? null;
15805
+ if (parentId !== null) {
15806
+ const parentVoice = state.voices.items[parentId];
15807
+ if (!isPlainObject(parentVoice)) {
15808
+ return invalidPrecondition(
15809
+ "payload.parentId must reference an existing voice item",
15810
+ );
15811
+ }
15812
+
15813
+ if (parentVoice.type !== "folder") {
15814
+ return invalidPrecondition(
15815
+ "payload.parentId must reference a folder voice item",
15816
+ );
15817
+ }
15818
+ }
15819
+
15820
+ if (payload.positionTargetId !== undefined) {
15821
+ if (!isPlainObject(state.voices.items[payload.positionTargetId])) {
15822
+ return invalidPrecondition(
15823
+ "payload.positionTargetId must reference an existing voice item",
15824
+ );
15825
+ }
15826
+
15827
+ const targetParentId = getNodeParentId({
15828
+ tree: state.voices.tree,
15829
+ nodeId: payload.positionTargetId,
15830
+ });
15831
+
15832
+ if (targetParentId !== parentId) {
15833
+ return invalidPrecondition(
15834
+ "payload.positionTargetId must reference a sibling under payload.parentId",
15835
+ );
15836
+ }
15837
+ }
15838
+
15839
+ if (payload.data.type === "voice") {
15840
+ const scene = state.scenes.items[payload.data.sceneId];
15841
+ if (!isPlainObject(scene) || scene.type === "folder") {
15842
+ return invalidPrecondition(
15843
+ "payload.data.sceneId must reference an existing non-folder scene",
15844
+ );
15845
+ }
15846
+
15847
+ return validateReferencedFilesInData({
15848
+ state,
15849
+ data: payload.data,
15850
+ fields: ["fileId", "waveformDataFileId"],
15851
+ nullableFields: ["waveformDataFileId"],
15852
+ details: {
15853
+ voiceId: payload.voiceId,
15854
+ },
15855
+ });
15856
+ }
15857
+ },
15858
+ reduce: ({ state, payload }) => {
15859
+ const nextVoice = {
15860
+ id: payload.voiceId,
15861
+ type: payload.data.type,
15862
+ name: payload.data.name,
15863
+ };
15864
+
15865
+ if (payload.data.description !== undefined) {
15866
+ nextVoice.description = payload.data.description;
15867
+ }
15868
+
15869
+ if (payload.data.type === "voice") {
15870
+ nextVoice.sceneId = payload.data.sceneId;
15871
+ nextVoice.fileId = payload.data.fileId;
15872
+ if (payload.data.waveformDataFileId !== undefined) {
15873
+ nextVoice.waveformDataFileId = payload.data.waveformDataFileId;
15874
+ }
15875
+ if (payload.data.duration !== undefined) {
15876
+ nextVoice.duration = payload.data.duration;
15877
+ }
15878
+ }
15879
+
15880
+ state.voices.items[payload.voiceId] = nextVoice;
15881
+
15882
+ insertTreeNode({
15883
+ tree: state.voices.tree,
15884
+ node: {
15885
+ id: payload.voiceId,
15886
+ children: [],
15887
+ },
15888
+ parentId: payload.parentId ?? null,
15889
+ index: payload.index,
15890
+ position: payload.position,
15891
+ positionTargetId: payload.positionTargetId,
15892
+ });
15893
+
15894
+ return state;
15895
+ },
15896
+ },
15897
+ {
15898
+ type: "voice.update",
15899
+ validatePayload: ({ payload }) => {
15900
+ {
15901
+ const result = validateExactKeys({
15902
+ value: payload,
15903
+ expectedKeys: ["voiceId", "data"],
15904
+ path: "payload",
15905
+ errorFactory: createPayloadValidationError,
15906
+ });
15907
+ if (result?.valid === false) {
15908
+ return result;
15909
+ }
15910
+ }
15911
+
15912
+ if (!isNonEmptyString(payload.voiceId)) {
15913
+ return invalidPayload("payload.voiceId must be a non-empty string");
15914
+ }
15915
+
15916
+ {
15917
+ const result = validateVoiceUpdateData({
15918
+ data: payload.data,
15919
+ errorFactory: createPayloadValidationError,
15920
+ });
15921
+ if (result?.valid === false) {
15922
+ return result;
15923
+ }
15924
+ }
15925
+ },
15926
+ validateAgainstState: ({ state, payload }) => {
15927
+ const currentVoice = state.voices.items[payload.voiceId];
15928
+ if (!isPlainObject(currentVoice)) {
15929
+ return invalidPrecondition(
15930
+ "payload.voiceId must reference an existing voice item",
15931
+ );
15932
+ }
15933
+
15934
+ if (
15935
+ currentVoice.type === "folder" &&
15936
+ (payload.data.sceneId !== undefined ||
15937
+ payload.data.fileId !== undefined ||
15938
+ payload.data.waveformDataFileId !== undefined ||
15939
+ payload.data.duration !== undefined)
15940
+ ) {
15941
+ return invalidPrecondition(
15942
+ "folder voice items cannot update file fields",
15943
+ );
15944
+ }
15945
+
15946
+ if (currentVoice.type === "voice") {
15947
+ if (payload.data.sceneId !== undefined) {
15948
+ const scene = state.scenes.items[payload.data.sceneId];
15949
+ if (!isPlainObject(scene) || scene.type === "folder") {
15950
+ return invalidPrecondition(
15951
+ "payload.data.sceneId must reference an existing non-folder scene",
15952
+ );
15953
+ }
15954
+ }
15955
+
15956
+ return validateReferencedFilesInData({
15957
+ state,
15958
+ data: payload.data,
15959
+ fields: ["fileId", "waveformDataFileId"],
15960
+ nullableFields: ["waveformDataFileId"],
15961
+ details: {
15962
+ voiceId: payload.voiceId,
15963
+ },
15964
+ });
15965
+ }
15966
+ },
15967
+ reduce: ({ state, payload }) => {
15968
+ state.voices.items[payload.voiceId] = {
15969
+ ...structuredClone(state.voices.items[payload.voiceId]),
15970
+ ...structuredClone(payload.data),
15971
+ };
15972
+ return state;
15973
+ },
15974
+ },
15975
+ {
15976
+ type: "voice.delete",
15977
+ validatePayload: ({ payload }) => {
15978
+ {
15979
+ const result = validateExactKeys({
15980
+ value: payload,
15981
+ expectedKeys: ["voiceIds"],
15982
+ path: "payload",
15983
+ errorFactory: createPayloadValidationError,
15984
+ });
15985
+ if (result?.valid === false) {
15986
+ return result;
15987
+ }
15988
+ }
15989
+
15990
+ {
15991
+ const result = validateRequiredUniqueIdArray({
15992
+ value: payload.voiceIds,
15993
+ path: "payload.voiceIds",
15994
+ errorFactory: createPayloadValidationError,
15995
+ });
15996
+ if (result?.valid === false) {
15997
+ return result;
15998
+ }
15999
+ }
16000
+ },
16001
+ validateAgainstState: ({ state, payload }) => {
16002
+ for (const voiceId of payload.voiceIds) {
16003
+ if (!isPlainObject(state.voices.items[voiceId])) {
16004
+ return invalidPrecondition(
16005
+ "payload.voiceIds must reference existing voice items",
16006
+ { voiceId },
16007
+ );
16008
+ }
16009
+ }
16010
+ },
16011
+ reduce: ({ state, payload }) => {
16012
+ const deletedVoiceIds = new Set();
16013
+
16014
+ for (const voiceId of payload.voiceIds) {
16015
+ const removedNode = removeTreeNode({
16016
+ nodes: state.voices.tree,
16017
+ nodeId: voiceId,
16018
+ });
16019
+
16020
+ if (!removedNode) {
16021
+ continue;
16022
+ }
16023
+
16024
+ for (const id of collectTreeDescendantIds({ node: removedNode })) {
16025
+ deletedVoiceIds.add(id);
16026
+ }
16027
+ }
16028
+
16029
+ for (const voiceId of deletedVoiceIds) {
16030
+ delete state.voices.items[voiceId];
16031
+ }
16032
+
16033
+ return state;
16034
+ },
16035
+ },
16036
+ {
16037
+ type: "voice.move",
16038
+ validatePayload: ({ payload }) => {
16039
+ {
16040
+ const result = validateAllowedKeys({
16041
+ value: payload,
16042
+ allowedKeys: [
16043
+ "voiceId",
16044
+ "parentId",
16045
+ "index",
16046
+ "position",
16047
+ "positionTargetId",
16048
+ ],
16049
+ path: "payload",
16050
+ errorFactory: createPayloadValidationError,
16051
+ });
16052
+ if (result?.valid === false) {
16053
+ return result;
16054
+ }
16055
+ }
16056
+
16057
+ if (!isNonEmptyString(payload.voiceId)) {
16058
+ return invalidPayload("payload.voiceId must be a non-empty string");
16059
+ }
16060
+
16061
+ if (
16062
+ payload.parentId !== undefined &&
16063
+ payload.parentId !== null &&
16064
+ !isNonEmptyString(payload.parentId)
16065
+ ) {
16066
+ return invalidPayload(
16067
+ "payload.parentId must be a non-empty string when provided",
16068
+ );
16069
+ }
16070
+
16071
+ {
16072
+ const result = validatePlacementFields({
16073
+ payload,
16074
+ errorFactory: createPayloadValidationError,
16075
+ });
16076
+ if (result?.valid === false) {
16077
+ return result;
16078
+ }
16079
+ }
16080
+ },
16081
+ validateAgainstState: ({ state, payload }) => {
16082
+ const voice = state.voices.items[payload.voiceId];
16083
+ if (!isPlainObject(voice)) {
16084
+ return invalidPrecondition(
16085
+ "payload.voiceId must reference an existing voice item",
16086
+ );
16087
+ }
16088
+
16089
+ const voiceNode = findTreeNode({
16090
+ nodes: state.voices.tree,
16091
+ nodeId: payload.voiceId,
16092
+ });
16093
+
16094
+ if (payload.parentId !== undefined && payload.parentId !== null) {
16095
+ const parentVoice = state.voices.items[payload.parentId];
16096
+ if (!isPlainObject(parentVoice)) {
16097
+ return invalidPrecondition(
16098
+ "payload.parentId must reference an existing voice item",
16099
+ );
16100
+ }
16101
+
16102
+ if (parentVoice.type !== "folder") {
16103
+ return invalidPrecondition(
16104
+ "payload.parentId must reference a folder voice item",
16105
+ );
16106
+ }
16107
+
16108
+ const descendantIds = new Set(
16109
+ collectTreeDescendantIds({
16110
+ node: voiceNode,
16111
+ }),
16112
+ );
16113
+
16114
+ if (descendantIds.has(payload.parentId)) {
16115
+ return invalidPrecondition(
16116
+ "payload.parentId must not target the moved voice item or its descendants",
16117
+ );
16118
+ }
16119
+ }
16120
+
16121
+ if (payload.positionTargetId !== undefined) {
16122
+ if (payload.positionTargetId === payload.voiceId) {
16123
+ return invalidPrecondition(
16124
+ "payload.positionTargetId must not reference the moved voice item",
16125
+ );
16126
+ }
16127
+
16128
+ if (!isPlainObject(state.voices.items[payload.positionTargetId])) {
16129
+ return invalidPrecondition(
16130
+ "payload.positionTargetId must reference an existing voice item",
16131
+ );
16132
+ }
16133
+
16134
+ const targetParentId = getNodeParentId({
16135
+ tree: state.voices.tree,
16136
+ nodeId: payload.positionTargetId,
16137
+ });
16138
+
16139
+ if (targetParentId !== (payload.parentId ?? null)) {
16140
+ return invalidPrecondition(
16141
+ "payload.positionTargetId must reference a sibling under payload.parentId",
16142
+ );
16143
+ }
16144
+ }
16145
+ },
16146
+ reduce: ({ state, payload }) => {
16147
+ const voiceNodeResult = removeNodeOrResult({
16148
+ tree: state.voices.tree,
16149
+ nodeId: payload.voiceId,
16150
+ errorMessage: "voice move target missing from tree",
16151
+ });
16152
+ if (!voiceNodeResult.valid) {
16153
+ return voiceNodeResult;
16154
+ }
16155
+
16156
+ insertTreeNode({
16157
+ tree: state.voices.tree,
16158
+ node: voiceNodeResult.node,
16159
+ parentId: payload.parentId ?? null,
16160
+ index: payload.index,
16161
+ position: payload.position,
16162
+ positionTargetId: payload.positionTargetId,
16163
+ });
16164
+
16165
+ return state;
16166
+ },
16167
+ },
15387
16168
  {
15388
16169
  type: "video.create",
15389
16170
  validatePayload: ({ payload }) => {