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.
- package/package.json +1 -1
- package/src/cli.mjs +43 -10
- package/src/messages.mjs +55 -8
- package/src/self-update.mjs +4 -0
- package/src/status-command.mjs +10 -2
- package/src/update-check.mjs +49 -10
- package/vendor/docs/contract-2026-07-17-data-contract-versioning.md +6 -0
- package/vendor/packages/akari-launcher/package.json +1 -1
- package/vendor/packages/edit-lint/src/edit-lint.mjs +153 -332
- package/vendor/packages/edit-store/lib/edit-store.d.ts +13 -4
- package/vendor/packages/edit-store/lib/edit-store.js +62 -45
- package/vendor/packages/edit-store/lib/edit-v2.d.ts +45 -4
- package/vendor/packages/edit-store/lib/edit-v2.js +72 -1
- package/vendor/packages/edit-store/lib/internal-model.d.ts +9 -0
- package/vendor/packages/edit-store/lib/internal-model.js +381 -41
- package/vendor/packages/edit-store/lib/migrate/index.js +225 -18
- package/vendor/packages/edit-store/lib/migrate/legacy-parse.js +5 -1
- package/vendor/packages/edit-store/lib/retime.js +14 -0
- package/vendor/packages/schemas/edit.schema.json +53 -3
- package/vendor/packages/schemas/examples/edit-v2-audio-track-valid/edit.json +56 -0
- package/vendor/packages/schemas/examples/edit-v2-valid/edit.json +38 -2
- package/vendor/packages/schemas/test/edit-v2-schema.test.mjs +106 -5
- package/vendor/skills/edit-plan/SKILL.md +1 -1
- package/vendor/skills/edit-plan/beat-sync.md +1 -1
- package/vendor/skills/edit-plan/beats.md +1 -1
- package/vendor/skills/edit-plan/emphasis-detection.md +1 -1
- 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
|
|
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
|
-
...
|
|
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
|
|
234
|
+
const audioOnlySourceIds = collectAudioOnlySourceIds(rawEdit);
|
|
235
|
+
const referenceState = await validateReferences(edit, findings, paths, audioOnlySourceIds);
|
|
235
236
|
const sourceDuration = null;
|
|
236
237
|
|
|
237
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
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 (
|
|
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 (
|
|
1722
|
+
if (!isNonEmptyString(item.id)) {
|
|
1603
1723
|
addFinding(findings, {
|
|
1604
1724
|
severity: "error",
|
|
1605
1725
|
check: "audio.narration.id",
|
|
1606
|
-
message: "id must
|
|
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
|
-
|
|
75
|
-
|
|
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;
|