akari-video 0.1.12 → 0.1.13

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +43 -10
  3. package/src/messages.mjs +55 -8
  4. package/src/self-update.mjs +4 -0
  5. package/src/status-command.mjs +10 -2
  6. package/src/update-check.mjs +49 -10
  7. package/vendor/docs/contract-2026-07-17-data-contract-versioning.md +6 -0
  8. package/vendor/packages/akari-launcher/package.json +1 -1
  9. package/vendor/packages/edit-lint/src/edit-lint.mjs +153 -332
  10. package/vendor/packages/edit-store/lib/edit-store.d.ts +13 -4
  11. package/vendor/packages/edit-store/lib/edit-store.js +62 -45
  12. package/vendor/packages/edit-store/lib/edit-v2.d.ts +45 -4
  13. package/vendor/packages/edit-store/lib/edit-v2.js +72 -1
  14. package/vendor/packages/edit-store/lib/internal-model.d.ts +9 -0
  15. package/vendor/packages/edit-store/lib/internal-model.js +381 -41
  16. package/vendor/packages/edit-store/lib/migrate/index.js +225 -18
  17. package/vendor/packages/edit-store/lib/migrate/legacy-parse.js +5 -1
  18. package/vendor/packages/edit-store/lib/retime.js +14 -0
  19. package/vendor/packages/schemas/edit.schema.json +53 -3
  20. package/vendor/packages/schemas/examples/edit-v2-audio-track-valid/edit.json +56 -0
  21. package/vendor/packages/schemas/examples/edit-v2-valid/edit.json +38 -2
  22. package/vendor/packages/schemas/test/edit-v2-schema.test.mjs +106 -5
  23. package/vendor/skills/edit-plan/SKILL.md +1 -1
  24. package/vendor/skills/edit-plan/beat-sync.md +1 -1
  25. package/vendor/skills/edit-plan/beats.md +1 -1
  26. package/vendor/skills/edit-plan/emphasis-detection.md +1 -1
  27. package/vendor/skills/edit-plan/execution.md +1 -1
@@ -22,6 +22,7 @@ const {
22
22
  projectLegacyEdit,
23
23
  readInternalEdit,
24
24
  resolveCaptionDisplay,
25
+ visualContentEndSeconds,
25
26
  } = createRequire(import.meta.url)("../../edit-store/lib/index.js");
26
27
 
27
28
  const VERSION = 1;
@@ -133,7 +134,7 @@ export async function lintProject(input, options = {}) {
133
134
  const internalEdit = readInternalEdit(rawEdit);
134
135
  const legacyEdit = projectLegacyEdit(internalEdit);
135
136
  const rawAudio = isRecord(rawEdit.audio) ? rawEdit.audio : {};
136
- const rawSfx = Array.isArray(rawAudio.sfx) ? rawAudio.sfx : [];
137
+ const projectedAudio = projectAudioForLint(internalEdit);
137
138
  edit = {
138
139
  ...rawEdit,
139
140
  ...legacyEdit,
@@ -144,8 +145,7 @@ export async function lintProject(input, options = {}) {
144
145
  output: rawEdit.output,
145
146
  audio: {
146
147
  ...rawAudio,
147
- ...(rawSfx.length > 0 ? { sfx: rawSfx }
148
- : legacyEdit.audioSfx.length > 0 ? { sfx: legacyEdit.audioSfx } : {}),
148
+ ...projectedAudio,
149
149
  },
150
150
  };
151
151
 
@@ -231,16 +231,23 @@ export async function lintProject(input, options = {}) {
231
231
 
232
232
  const structure = validateEditStructure(edit, findings, paths);
233
233
  const sourcePath = structure.sourcePath;
234
- const referenceState = await validateReferences(edit, findings, paths);
234
+ const audioOnlySourceIds = collectAudioOnlySourceIds(rawEdit);
235
+ const referenceState = await validateReferences(edit, findings, paths, audioOnlySourceIds);
235
236
  const sourceDuration = null;
236
237
 
237
- const timeline = validateCuts(
238
+ const cutsStructureResult = validateCuts(
238
239
  edit.cuts,
239
240
  sourceDuration,
240
241
  findings,
241
242
  paths,
242
243
  structure.sourceIds,
243
244
  );
245
+ // 総尺の正本定義は「cuts の合計」ではなく「全 visual トラックのアイテムの最大終端」
246
+ // (packages/edit-store の visualContentEndSeconds、render-cut と共有)。段(トラック)を
247
+ // 移動しても cuts/layers の振り分けが変わるだけで total は動かない
248
+ // (P0 2026-08-20 track-identity-and-duration 指示 2)。cuts が構造的に不正なときは
249
+ // 従来どおり timeline を null にして下流の尺検証を止める。
250
+ const timeline = cutsStructureResult === null ? null : visualContentEndSeconds(internalEdit);
244
251
  validateCutTrackFields(edit.cuts, findings);
245
252
  validateCutTransformFields(edit.cuts, findings);
246
253
  validateStillImageCuts(edit, findings);
@@ -274,10 +281,7 @@ export async function lintProject(input, options = {}) {
274
281
  validateAudioMaster(edit?.audio?.master, findings, "edit.json#audio.master");
275
282
  validateOutputEncoding(edit?.output?.encoding, findings, "edit.json#output.encoding");
276
283
  validateLayerTracks(edit.layers, findings);
277
- validateBeats(edit.beats, structure.sourceIds, findings);
278
- validateEmphasisWords(edit.emphasis_words, structure.sourceIds, findings);
279
- validateDirection(edit.direction, findings);
280
- validateTimelineTracks(edit, findings);
284
+ validateTimelineTracks(edit, findings, collectInternalAudioTrackRefs(internalEdit));
281
285
  validateTrackTransitionOutCompatibility(edit, findings);
282
286
 
283
287
  if (captionsState.value !== undefined) {
@@ -307,6 +311,50 @@ export async function lintProject(input, options = {}) {
307
311
  return writeResult(findings, skipped, inputs, paths, options);
308
312
  }
309
313
 
314
+ function projectAudioForLint(internalEdit) {
315
+ const sfx = [];
316
+ const narration = [];
317
+ let bgm;
318
+ for (const track of internalEdit.tracks) {
319
+ if (track.lane !== "audio") continue;
320
+ for (const item of track.items) {
321
+ if (!isRecord(item.declaration)) continue;
322
+ const declaration = { ...item.declaration };
323
+ if (item.legacy.collection === "sfx") sfx.push(declaration);
324
+ if (item.legacy.collection === "narration") narration.push(declaration);
325
+ if (item.legacy.collection === "bgm") bgm = declaration;
326
+ }
327
+ }
328
+ return {
329
+ ...(sfx.length > 0 ? { sfx } : {}),
330
+ ...(narration.length > 0 ? { narration } : {}),
331
+ ...(bgm !== undefined ? { bgm } : {}),
332
+ };
333
+ }
334
+
335
+ function collectAudioOnlySourceIds(edit) {
336
+ if (!Array.isArray(edit?.tracks)) return new Set();
337
+ const audio = new Set();
338
+ const visual = new Set();
339
+ for (const track of edit.tracks) {
340
+ if (!isRecord(track) || !Array.isArray(track.items)) continue;
341
+ for (const item of track.items) {
342
+ const sourceId = isRecord(item?.source) && item.source.kind === "media"
343
+ ? item.source.src : undefined;
344
+ if (!isNonEmptyString(sourceId)) continue;
345
+ if (track.lane === "audio") audio.add(sourceId);
346
+ if (track.lane === "visual") visual.add(sourceId);
347
+ }
348
+ }
349
+ return new Set([...audio].filter(sourceId => !visual.has(sourceId)));
350
+ }
351
+
352
+ function collectInternalAudioTrackRefs(internalEdit) {
353
+ return new Set(internalEdit.tracks
354
+ .filter(track => track.lane === "audio" && track.items.length > 0)
355
+ .map(track => Number.isInteger(track.legacy.ref) ? track.legacy.ref : 0));
356
+ }
357
+
310
358
  async function writeResult(findings, skipped, inputs, paths, options) {
311
359
  const normalizedFindings = finalizeFindings(findings);
312
360
  const normalizedSkipped = finalizeSkipped(skipped);
@@ -538,6 +586,21 @@ function validateEditV2(edit, findings) {
538
586
  }
539
587
  }
540
588
 
589
+ const bgmItems = edit.tracks.flatMap((track, trackIndex) =>
590
+ isRecord(track) && track.lane === "audio" && Array.isArray(track.items)
591
+ ? track.items.flatMap((item, itemIndex) =>
592
+ isRecord(item) && item.role === "bgm" ? [{ trackIndex, itemIndex }] : [])
593
+ : []
594
+ );
595
+ if (bgmItems.length > 1) {
596
+ addFinding(findings, {
597
+ severity: "error",
598
+ check: "v2.audio-bgm-multiple",
599
+ message: "audio lane items may declare at most one bgm role",
600
+ path: "edit.json#tracks",
601
+ });
602
+ }
603
+
541
604
  for (const [trackIndex, track] of edit.tracks.entries()) {
542
605
  const trackPath = `edit.json#tracks[${trackIndex}]`;
543
606
  if (!isRecord(track)) {
@@ -591,6 +654,52 @@ function validateEditV2(edit, findings) {
591
654
  });
592
655
  }
593
656
 
657
+ if (track.lane === "audio") {
658
+ const role = item.role ?? "sfx";
659
+ if (Object.hasOwn(item, "gain_db")
660
+ && (!isFiniteNumber(item.gain_db) || item.gain_db < -60 || item.gain_db > 12)) {
661
+ addFinding(findings, {
662
+ severity: "error",
663
+ check: `audio.${role}.gain-db`,
664
+ message: "gain_db must be a finite number within [-60, 12]",
665
+ path: `${itemPath}.gain_db`,
666
+ });
667
+ }
668
+ for (const [field, bgmField] of [["fade_in", "fadeIn"], ["fade_out", "fadeOut"]]) {
669
+ if (!Object.hasOwn(item, field) || (isFiniteNumber(item[field]) && item[field] >= 0)) continue;
670
+ addFinding(findings, {
671
+ severity: "error",
672
+ check: role === "bgm" ? `audio.bgm.${bgmField}` : `audio.sfx.${field}`,
673
+ message: `${field} must be a non-negative finite number`,
674
+ path: `${itemPath}.${field}`,
675
+ });
676
+ }
677
+ if (isRecord(item.source)
678
+ && isFiniteNumber(item.source.in)
679
+ && isFiniteNumber(item.source.out)
680
+ && item.source.out <= item.source.in) {
681
+ addFinding(findings, {
682
+ severity: "error",
683
+ check: "audio.sfx.in-out",
684
+ message: "sfx must satisfy in < out when both are present",
685
+ path: `${itemPath}.source`,
686
+ range: { start: item.source.in, end: item.source.out },
687
+ });
688
+ }
689
+ }
690
+
691
+ // Visual items with duration: 0 represent nothing renderable and remain invalid. Audio
692
+ // items deliberately use 0 as the unresolved-material-duration sentinel when a legacy
693
+ // declaration omits an explicit out point; render-cut resolves that duration from media.
694
+ if (track.lane !== "audio" && Number.isInteger(item.duration) && item.duration === 0) {
695
+ addFinding(findings, {
696
+ severity: "error",
697
+ check: "v2.item-duration",
698
+ message: "item duration must be a positive integer (0 represents nothing on the timeline)",
699
+ path: `${itemPath}.duration`,
700
+ });
701
+ }
702
+
594
703
  if (Number.isInteger(item.at) && item.at >= 0 && Number.isInteger(item.duration) && item.duration >= 0) {
595
704
  const transitionSeconds = isRecord(item.source?.transition_out)
596
705
  && isPositiveNumber(item.source.transition_out.duration)
@@ -760,14 +869,24 @@ function findTrackOverlaps(segments) {
760
869
  return overlaps;
761
870
  }
762
871
 
872
+ // P0 2026-08-21 render-path-unification (MAJOR-3 fix, Codex review): render-cut now auto-clamps
873
+ // a declared transition_out.duration down to whatever overlap an explicit `at` actually provides
874
+ // (packages/render-cut/src/cut-timeline.mjs's effectiveTransitionDurations) whenever that overlap
875
+ // is real (positive) but shorter than declared, rendering a genuinely shorter dissolve instead of
876
+ // silently hard-cutting and dropping frames -- so this check must accept that same range as a
877
+ // valid, declared transition (not only an exact duration match), or a project render-cut can now
878
+ // render correctly would still fail lint. Still rejects zero/negative overlap (a genuine gap --
879
+ // no transition is physically possible) and overlap greater than declared (an unrelated shape
880
+ // render-cut does not auto-adjust for -- see effectiveTransitionDurations' own comment).
763
881
  function isDeclaredTransitionOverlap(cuts, segments, current) {
764
882
  const previous = segments
765
883
  .filter(segment => segment.track === current.track && segment.index < current.index)
766
884
  .sort((left, right) => right.index - left.index)[0];
767
885
  if (!previous) return false;
768
886
  const duration = cuts?.[previous.index]?.transition_out?.duration;
769
- return isPositiveNumber(duration)
770
- && Math.abs((previous.end - current.start) - duration) <= EPSILON;
887
+ if (!isPositiveNumber(duration)) return false;
888
+ const availableOverlap = previous.end - current.start;
889
+ return availableOverlap > EPSILON && availableOverlap <= duration + EPSILON;
771
890
  }
772
891
 
773
892
  function validateCutTrackFields(cuts, findings) {
@@ -1007,7 +1126,7 @@ function validateSfxTracks(sfx, findings) {
1007
1126
  }
1008
1127
  }
1009
1128
 
1010
- function validateTimelineTracks(edit, findings) {
1129
+ function validateTimelineTracks(edit, findings, projectedAudioTracks = null) {
1011
1130
  const timeline = edit?.timeline;
1012
1131
  if (timeline === undefined || timeline === null) return;
1013
1132
  if (!isRecord(timeline)) {
@@ -1029,8 +1148,9 @@ function validateTimelineTracks(edit, findings) {
1029
1148
  return;
1030
1149
  }
1031
1150
 
1032
- const audioTracks = collectActualTrackNumbers(edit?.audio?.sfx);
1033
- if (isRecord(edit?.audio?.bgm) || (Array.isArray(edit?.audio?.narration) && edit.audio.narration.length > 0)) {
1151
+ const audioTracks = projectedAudioTracks ?? collectActualTrackNumbers(edit?.audio?.sfx);
1152
+ if (projectedAudioTracks === null
1153
+ && (isRecord(edit?.audio?.bgm) || (Array.isArray(edit?.audio?.narration) && edit.audio.narration.length > 0))) {
1034
1154
  audioTracks.add(0);
1035
1155
  }
1036
1156
  const actualTracks = new Map([
@@ -1599,11 +1719,11 @@ async function validateNarration(narration, timeline, findings, paths) {
1599
1719
  continue;
1600
1720
  }
1601
1721
 
1602
- if (typeof item.id !== "string" || !/^n-\d{4}$/.test(item.id)) {
1722
+ if (!isNonEmptyString(item.id)) {
1603
1723
  addFinding(findings, {
1604
1724
  severity: "error",
1605
1725
  check: "audio.narration.id",
1606
- message: "id must match n- followed by four digits",
1726
+ message: "id must be a non-empty string",
1607
1727
  path: itemPath,
1608
1728
  });
1609
1729
  } else if (ids.has(item.id)) {
@@ -1676,311 +1796,6 @@ async function validateNarration(narration, timeline, findings, paths) {
1676
1796
  });
1677
1797
  }
1678
1798
 
1679
- if (!isRecord(item.provenance)) {
1680
- addFinding(findings, {
1681
- severity: "error",
1682
- check: "audio.narration.provenance",
1683
- message: "provenance is required and must be an object",
1684
- path: itemPath,
1685
- });
1686
- } else if (!isNonEmptyString(item.provenance.provider)) {
1687
- addFinding(findings, {
1688
- severity: "error",
1689
- check: "audio.narration.provenance",
1690
- message: "provenance.provider must be a non-empty string",
1691
- path: itemPath,
1692
- });
1693
- } else if (
1694
- item.provenance.provider === "voicevox" &&
1695
- !isNonEmptyString(item.provenance.credit)
1696
- ) {
1697
- addFinding(findings, {
1698
- severity: "error",
1699
- check: "audio.narration.credit",
1700
- message: "provenance.credit is required when provider is voicevox",
1701
- path: itemPath,
1702
- });
1703
- }
1704
- }
1705
- }
1706
-
1707
- // docs/contract-2026-07-22-edit-json-v1-beats.md §7: beats(見せ場マーカー)の構造検証は
1708
- // validate-edit.mjs と同一のルールを手書きで写す流儀(edit-lint は依存ゼロのため他パッケージの
1709
- // バリデータを import しない)。beats[].t は timeline 秒ではなく source 秒アンカー(同 §3)であり、
1710
- // source 実尺との突き合わせは --media なしでデコードしない規律のため行わない(同 §7 の将来課題)。
1711
- // beats フィールドの不在はエラーにしない(同 §4 の劣化規約)。
1712
- function validateBeats(beats, sourceIds, findings) {
1713
- if (beats === undefined) return;
1714
- if (!Array.isArray(beats)) {
1715
- addFinding(findings, {
1716
- severity: "error",
1717
- check: "beats.structure",
1718
- message: "beats must be an array",
1719
- path: "edit.json#beats",
1720
- });
1721
- return;
1722
- }
1723
-
1724
- const ids = new Set();
1725
- for (const [index, item] of beats.entries()) {
1726
- const itemPath = `edit.json#beats[${index}]`;
1727
- if (!isRecord(item)) {
1728
- addFinding(findings, {
1729
- severity: "error",
1730
- check: "beats.structure",
1731
- message: "beat item must be an object",
1732
- path: itemPath,
1733
- });
1734
- continue;
1735
- }
1736
-
1737
- if (typeof item.id !== "string" || !/^b-\d{4}$/.test(item.id)) {
1738
- addFinding(findings, {
1739
- severity: "error",
1740
- check: "beats.id",
1741
- message: "id must match b- followed by four digits",
1742
- path: itemPath,
1743
- });
1744
- } else if (ids.has(item.id)) {
1745
- addFinding(findings, {
1746
- severity: "error",
1747
- check: "beats.id",
1748
- message: `duplicate beat id: ${item.id}`,
1749
- path: itemPath,
1750
- });
1751
- } else {
1752
- ids.add(item.id);
1753
- }
1754
-
1755
- if (!isFiniteNumber(item.t) || item.t < 0) {
1756
- addFinding(findings, {
1757
- severity: "error",
1758
- check: "beats.t",
1759
- message: "t must be a non-negative finite number (source seconds)",
1760
- path: itemPath,
1761
- });
1762
- }
1763
-
1764
- if (!isNonEmptyString(item.kind)) {
1765
- addFinding(findings, {
1766
- severity: "error",
1767
- check: "beats.kind",
1768
- message: "kind must be a non-empty string",
1769
- path: itemPath,
1770
- });
1771
- }
1772
-
1773
- if (!isFiniteNumber(item.strength) || item.strength < 0 || item.strength > 1) {
1774
- addFinding(findings, {
1775
- severity: "error",
1776
- check: "beats.strength",
1777
- message: "strength must be a finite number within [0, 1]",
1778
- path: itemPath,
1779
- });
1780
- }
1781
-
1782
- if (Object.hasOwn(item, "basis") && typeof item.basis !== "string") {
1783
- addFinding(findings, {
1784
- severity: "error",
1785
- check: "beats.basis",
1786
- message: "basis must be a string",
1787
- path: itemPath,
1788
- });
1789
- }
1790
-
1791
- if (Object.hasOwn(item, "src")) {
1792
- if (!isNonEmptyString(item.src)) {
1793
- addFinding(findings, {
1794
- severity: "error",
1795
- check: "beats.src",
1796
- message: "src must be a non-empty string",
1797
- path: itemPath,
1798
- });
1799
- } else if (!sourceIds.has(item.src)) {
1800
- addFinding(findings, {
1801
- severity: "error",
1802
- check: "beats.src-reference",
1803
- message: `src does not reference sources[].id: ${item.src}`,
1804
- path: itemPath,
1805
- });
1806
- }
1807
- }
1808
- }
1809
- }
1810
-
1811
- // docs/contract-2026-07-23-edit-json-v1-emphasis-words.md §7: emphasis_words(語レベル演出)の
1812
- // 構造検証は validate-edit.mjs と同一のルールを手書きで写す流儀(edit-lint は依存ゼロのため他パッケージの
1813
- // バリデータを import しない)。t_start/t_end は timeline 秒ではなく source 秒アンカー(同 §3)であり、
1814
- // source 実尺との突き合わせは --media なしでデコードしない規律のため行わない(同 §7 の将来課題)。
1815
- // 各要素は素材ファイルを参照しないためファイル実在チェックも行わない。
1816
- // emphasis_words フィールドの不在はエラーにしない(同 §5 の劣化規約)。
1817
- function validateEmphasisWords(emphasisWords, sourceIds, findings) {
1818
- if (emphasisWords === undefined) return;
1819
- if (!Array.isArray(emphasisWords)) {
1820
- addFinding(findings, {
1821
- severity: "error",
1822
- check: "emphasis_words.structure",
1823
- message: "emphasis_words must be an array",
1824
- path: "edit.json#emphasis_words",
1825
- });
1826
- return;
1827
- }
1828
-
1829
- const ids = new Set();
1830
- for (const [index, item] of emphasisWords.entries()) {
1831
- const itemPath = `edit.json#emphasis_words[${index}]`;
1832
- if (!isRecord(item)) {
1833
- addFinding(findings, {
1834
- severity: "error",
1835
- check: "emphasis_words.structure",
1836
- message: "emphasis word item must be an object",
1837
- path: itemPath,
1838
- });
1839
- continue;
1840
- }
1841
-
1842
- if (typeof item.id !== "string" || !/^e-\d{4}$/.test(item.id)) {
1843
- addFinding(findings, {
1844
- severity: "error",
1845
- check: "emphasis_words.id",
1846
- message: "id must match e- followed by four digits",
1847
- path: itemPath,
1848
- });
1849
- } else if (ids.has(item.id)) {
1850
- addFinding(findings, {
1851
- severity: "error",
1852
- check: "emphasis_words.id",
1853
- message: `duplicate emphasis word id: ${item.id}`,
1854
- path: itemPath,
1855
- });
1856
- } else {
1857
- ids.add(item.id);
1858
- }
1859
-
1860
- const hasStart = isFiniteNumber(item.t_start) && item.t_start >= 0;
1861
- const hasEnd = isFiniteNumber(item.t_end) && item.t_end >= 0;
1862
- if (!hasStart) {
1863
- addFinding(findings, {
1864
- severity: "error",
1865
- check: "emphasis_words.t_start",
1866
- message: "t_start must be a non-negative finite number (source seconds)",
1867
- path: itemPath,
1868
- });
1869
- }
1870
- if (!hasEnd) {
1871
- addFinding(findings, {
1872
- severity: "error",
1873
- check: "emphasis_words.t_end",
1874
- message: "t_end must be a non-negative finite number (source seconds)",
1875
- path: itemPath,
1876
- });
1877
- }
1878
- if (hasStart && hasEnd && item.t_end <= item.t_start) {
1879
- addFinding(findings, {
1880
- severity: "error",
1881
- check: "emphasis_words.range",
1882
- message: "t_end must be greater than t_start",
1883
- path: itemPath,
1884
- range: { start: item.t_start, end: item.t_end },
1885
- });
1886
- }
1887
-
1888
- if (!isNonEmptyString(item.word)) {
1889
- addFinding(findings, {
1890
- severity: "error",
1891
- check: "emphasis_words.word",
1892
- message: "word must be a non-empty string",
1893
- path: itemPath,
1894
- });
1895
- }
1896
-
1897
- if (!isNonEmptyString(item.emotion)) {
1898
- addFinding(findings, {
1899
- severity: "error",
1900
- check: "emphasis_words.emotion",
1901
- message: "emotion must be a non-empty string",
1902
- path: itemPath,
1903
- });
1904
- }
1905
-
1906
- if (Object.hasOwn(item, "style_hint") && typeof item.style_hint !== "string") {
1907
- addFinding(findings, {
1908
- severity: "error",
1909
- check: "emphasis_words.style-hint",
1910
- message: "style_hint must be a string",
1911
- path: itemPath,
1912
- });
1913
- }
1914
-
1915
- if (Object.hasOwn(item, "src")) {
1916
- if (!isNonEmptyString(item.src)) {
1917
- addFinding(findings, {
1918
- severity: "error",
1919
- check: "emphasis_words.src",
1920
- message: "src must be a non-empty string",
1921
- path: itemPath,
1922
- });
1923
- } else if (!sourceIds.has(item.src)) {
1924
- addFinding(findings, {
1925
- severity: "error",
1926
- check: "emphasis_words.src-reference",
1927
- message: `src does not reference sources[].id: ${item.src}`,
1928
- path: itemPath,
1929
- });
1930
- }
1931
- }
1932
- }
1933
- }
1934
-
1935
- // docs/contract-2026-07-23-edit-json-v1-direction.md §6: direction(演出宣言)の構造検証は
1936
- // validate-edit.mjs と同一のルールを手書きで写す流儀(edit-lint は依存ゼロのため他パッケージの
1937
- // バリデータを import しない)。direction は配列ではなくオブジェクト = 宣言はファイルに 1 つ(同 §1)。
1938
- // preset の値が実在するプリセットかは検証しない(enum 強制をしないため・同 §6)。
1939
- // overrides は語彙が未定義のため object であることだけを見る。
1940
- // direction フィールドの不在はエラーにしない(同 §5 の劣化規約)。
1941
- function validateDirection(direction, findings) {
1942
- if (direction === undefined) return;
1943
- if (!isRecord(direction)) {
1944
- addFinding(findings, {
1945
- severity: "error",
1946
- check: "direction.structure",
1947
- message: "direction must be an object",
1948
- path: "edit.json#direction",
1949
- });
1950
- return;
1951
- }
1952
-
1953
- if (!isNonEmptyString(direction.preset)) {
1954
- addFinding(findings, {
1955
- severity: "error",
1956
- check: "direction.preset",
1957
- message: "preset must be a non-empty string",
1958
- path: "edit.json#direction",
1959
- });
1960
- }
1961
-
1962
- if (Object.hasOwn(direction, "intensity")) {
1963
- if (
1964
- !Number.isInteger(direction.intensity) ||
1965
- direction.intensity < 0 ||
1966
- direction.intensity > 100
1967
- ) {
1968
- addFinding(findings, {
1969
- severity: "error",
1970
- check: "direction.intensity",
1971
- message: "intensity must be an integer within [0, 100]",
1972
- path: "edit.json#direction",
1973
- });
1974
- }
1975
- }
1976
-
1977
- if (Object.hasOwn(direction, "overrides") && !isRecord(direction.overrides)) {
1978
- addFinding(findings, {
1979
- severity: "error",
1980
- check: "direction.overrides",
1981
- message: "overrides must be an object",
1982
- path: "edit.json#direction",
1983
- });
1984
1799
  }
1985
1800
  }
1986
1801
 
@@ -2381,7 +2196,7 @@ function resolveBgmTrackId(bgmPath, declarations) {
2381
2196
  return baseNoExt;
2382
2197
  }
2383
2198
 
2384
- async function validateReferences(edit, findings, paths) {
2199
+ async function validateReferences(edit, findings, paths, ignoredSourceIds = new Set()) {
2385
2200
  const references = [];
2386
2201
  if (isRecord(edit?.source)) {
2387
2202
  references.push({ label: "source.path", value: edit.source.path, source: true });
@@ -2392,6 +2207,7 @@ async function validateReferences(edit, findings, paths) {
2392
2207
  if (Array.isArray(edit?.sources)) {
2393
2208
  for (const [index, source] of edit.sources.entries()) {
2394
2209
  if (!isRecord(source)) continue;
2210
+ if (ignoredSourceIds.has(source.id)) continue;
2395
2211
  references.push({
2396
2212
  label: `sources[${index}].path`,
2397
2213
  value: source.path,
@@ -2496,6 +2312,8 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
2496
2312
  // 常に全件発火する警告は本物の指摘を埋めるだけなので、規則ごと落とすのが正しい。
2497
2313
  // 撤去の証跡は edit-lint.test.mjs の "captions.overlay-link は発火しない" で固定してある。
2498
2314
  let previousStart = -Infinity;
2315
+ let furthestEnd = -Infinity;
2316
+ let furthestCaption = null;
2499
2317
 
2500
2318
  for (const [index, caption] of captions.entries()) {
2501
2319
  const itemPath = `captions.json#[${index}]`;
@@ -2617,6 +2435,19 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
2617
2435
  );
2618
2436
  }
2619
2437
  previousStart = caption.start;
2438
+ if (caption.start < furthestEnd - EPSILON) {
2439
+ addFinding(findings, {
2440
+ severity: "error",
2441
+ check: "captions.overlap",
2442
+ message: `caption overlaps ${furthestCaption.id ?? furthestCaption.path} on the same track`,
2443
+ path: itemPath,
2444
+ range: { start: caption.start, end: caption.end },
2445
+ });
2446
+ }
2447
+ if (caption.end > furthestEnd) {
2448
+ furthestEnd = caption.end;
2449
+ furthestCaption = { id: caption.id, path: itemPath };
2450
+ }
2620
2451
  const displaySeconds = caption.end - caption.start;
2621
2452
  if (displaySeconds < 1.0 - EPSILON) {
2622
2453
  addFinding(findings, {
@@ -2669,16 +2500,6 @@ function validateCaptions(captions, edit, analysis, findings, paths) {
2669
2500
  }
2670
2501
 
2671
2502
  if (displayPolicy !== undefined) {
2672
- const policyCaptions = Array.isArray(captionsRoot?.captions) ? captionsRoot.captions : [];
2673
- const emphasis = Array.isArray(edit?.emphasis_words) ? edit.emphasis_words : [];
2674
- for (const [index, caption] of policyCaptions.entries()) {
2675
- if (!isRecord(caption)) continue;
2676
- const conflict = emphasis.some(word => isRecord(word)
2677
- && (!isNonEmptyString(word.src) || !isNonEmptyString(caption.src) || word.src === caption.src)
2678
- && isFiniteNumber(word.t_start) && isFiniteNumber(word.t_end)
2679
- && word.t_end > caption.start && word.t_start < caption.end);
2680
- if (conflict) captionFinding(findings, "captions.display-policy-emphasis", "emphasis_words cannot act on a cue under display_policy v1", `captions.json#[${index}]`);
2681
- }
2682
2503
  try {
2683
2504
  resolveCaptionDisplay(captionsRoot, captionDisplayEdit(edit));
2684
2505
  } catch (error) {
@@ -70,16 +70,24 @@ export interface EditAudioSfx {
70
70
  in?: number;
71
71
  out?: number;
72
72
  }
73
- /**
74
- * audio.narration[] の表示用最小形(スキーマ: id は n-NNNN、t は秒、duration は持たない —
75
- * 実尺は音声ファイルから解決する)。provenance 等の残りは表示に使わないため保持しない。
76
- */
73
+ export interface EditAudioNarrationProvenance {
74
+ provider: string;
75
+ engine?: string;
76
+ voice?: string;
77
+ credit?: string;
78
+ generated_at?: string;
79
+ [key: string]: unknown;
80
+ }
81
+ /** audio.narration[] の legacy 投影形。 */
77
82
  export interface EditAudioNarration {
78
83
  id: string;
79
84
  t: number;
80
85
  path: string;
86
+ track?: number;
81
87
  gainDb?: number;
82
88
  script?: string;
89
+ reading?: string;
90
+ provenance?: EditAudioNarrationProvenance;
83
91
  }
84
92
  export interface CutTrackSegment {
85
93
  index: number;
@@ -91,6 +99,7 @@ export interface CutTrackSegment {
91
99
  export interface EditAudioBgm {
92
100
  id: 'bgm';
93
101
  path: string;
102
+ track?: number;
94
103
  fadeIn?: number;
95
104
  fadeOut?: number;
96
105
  gainDb?: number;