@oh-story/dsh 0.1.17 → 0.1.19
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/README.md +14 -14
- package/lib/client.js +147 -96
- package/lib/client.js.map +3 -3
- package/lib/drama/manifest.json +77 -247
- package/lib/drama/skills/short-drama/SKILL.md +9 -1
- package/lib/drama/skills/short-drama/references/creator-documents.md +51 -23
- package/lib/drama/skills/short-drama/references/reference-roles.md +1 -1
- package/lib/drama/skills/short-drama/scripts/creator_markdown_check.py +442 -129
- package/lib/drama/skills/short-drama/scripts/project_tool.py +54 -1
- package/lib/drama/skills/short-drama-assets/SKILL.md +2 -6
- package/lib/drama/skills/short-drama-assets/references/continuity-lock.md +9 -3
- package/lib/drama/skills/short-drama-assets/references/era-anchors.md +3 -3
- package/lib/drama/skills/short-drama-assets/references/location-and-view.md +4 -0
- package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +2 -2
- package/lib/drama/skills/short-drama-develop/references/episode-design.md +19 -4
- package/lib/drama/skills/short-drama-develop/references/genre-cards//347/263/273/347/273/237/346/265/201.md +1 -1
- package/lib/drama/skills/short-drama-edit/SKILL.md +1 -1
- package/lib/drama/skills/short-drama-edit/scripts/edit_tool.py +28 -6
- package/lib/drama/skills/short-drama-edit/scripts/selftest.py +1 -1
- package/lib/drama/skills/short-drama-image-prompts/SKILL.md +1 -5
- package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +2 -2
- package/lib/drama/skills/short-drama-produce/SKILL.md +2 -0
- package/lib/drama/skills/short-drama-review/SKILL.md +0 -4
- package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +1 -1
- package/lib/drama/skills/short-drama-storyboard/SKILL.md +9 -9
- package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +43 -13
- package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +5 -5
- package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +5 -5
- package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +27 -3
- package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +2 -1
- package/lib/drama/skills/short-drama-video-prompts/SKILL.md +6 -8
- package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +16 -1
- package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +23 -6
- package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +2 -2
- package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +8 -1
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.0.md +41 -2
- package/lib/drama/skills/short-drama-video-prompts/references/seedance-2.5.md +1 -1
- package/lib/drama/skills/short-drama-video-prompts/references/target-model-profile.md +5 -1
- package/lib/drama/skills/short-drama-write/SKILL.md +7 -3
- package/lib/drama/skills/short-drama-write/references/screenplay-format.md +1 -1
- package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +94 -1
- package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +13 -4
- package/lib/drama/skills/short-drama-write/scripts/selftest.py +3 -77
- package/lib/index.js +18 -14
- package/lib/index.js.map +2 -2
- package/lib/novel-to-game/README.md +20 -34
- package/lib/novel-to-game/README_ZH.md +20 -34
- package/lib/novel-to-game/VERSION +1 -1
- package/lib/novel-to-game/examples/jin-ping-mei/build/BUILD_BRIEF.md +2 -6
- package/lib/novel-to-game/examples/jin-ping-mei/build/app/js/audio.js +33 -39
- package/lib/novel-to-game/examples/jin-ping-mei/qa/README.md +2 -2
- package/lib/novel-to-game/manifest.json +46 -46
- package/lib/novel-to-game/skills/game-art-direction/SKILL.md +1 -1
- package/lib/novel-to-game/skills/game-build/references/baked-sprite-workflow.md +2 -3
- package/lib/novel-to-game/skills/game-build/references/blender-asset-workflow.md +6 -7
- package/lib/novel-to-game/skills/game-build/references/build-brief-contract.md +2 -2
- package/lib/novel-to-game/skills/game-build/references/generative-media-pipeline.md +1 -4
- package/lib/novel-to-game/skills/game-build/references/playable-model-contract.md +1 -3
- package/lib/novel-to-game/skills/game-build/references/tts-production-contract.md +1 -3
- package/lib/novel-to-game/skills/game-concept/SKILL.md +1 -1
- package/lib/novel-to-game/skills/game-qa/SKILL.md +3 -4
- package/lib/novel-to-game/skills/game-qa/references/qa-contract.md +1 -1
- package/lib/novel-to-game/skills/game-qa/references/test-design-method.md +3 -2
- package/lib/novel-to-game/skills/novel-game-analyze/SKILL.md +13 -14
- package/lib/novel-to-game/skills/novel-game-analyze/references/gameability-protocol.md +1 -2
- package/lib/novel-to-game/skills/novel-to-game/SKILL.md +4 -4
- package/lib/novel-to-game/skills/novel-to-game/references/intake-method.md +2 -3
- package/lib/novel-to-game/skills/novel-to-game/references/pipeline-contract.md +0 -8
- package/lib/oh-story/manifest.json +49 -49
- package/lib/oh-story/skills/story/SKILL.md +2 -2
- package/lib/oh-story/skills/story/VERSION +1 -1
- package/lib/oh-story/skills/story-deslop/SKILL.md +2 -2
- package/lib/oh-story/skills/story-import/SKILL.md +5 -5
- package/lib/oh-story/skills/story-import/references/deep-analysis.md +1 -1
- package/lib/oh-story/skills/story-import/references/import-report.md +1 -1
- package/lib/oh-story/skills/story-import/references/import-tracking.md +1 -1
- package/lib/oh-story/skills/story-import/references/outline-reverse.md +1 -1
- package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +6 -1
- package/lib/oh-story/skills/story-long-analyze/SKILL.md +2 -2
- package/lib/oh-story/skills/story-long-write/SKILL.md +2 -2
- package/lib/oh-story/skills/story-long-write/references/agent-calls.md +3 -3
- package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +2 -2
- package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +6 -1
- package/lib/oh-story/skills/story-review/SKILL.md +8 -7
- package/lib/oh-story/skills/story-review/references/agent-prompts.md +7 -7
- package/lib/oh-story/skills/story-review/references/tracking-transaction.md +2 -2
- package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +6 -1
- package/lib/oh-story/skills/story-setup/SKILL.md +6 -6
- package/lib/oh-story/skills/story-short-write/SKILL.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/agent-calls.md +3 -3
- package/lib/oh-story/skills/story-short-write/references/workflow-design.md +2 -2
- package/lib/oh-story/skills/story-short-write/references/workflow-revision.md +1 -1
- package/lib/types/client/game-preview-state.d.ts +34 -0
- package/lib/types/client/game-preview-state.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/video-studio.d.ts +24 -0
- package/lib/types/client/video-studio.d.ts.map +1 -1
- package/lib/types/host-python.d.ts +10 -3
- package/lib/types/host-python.d.ts.map +1 -1
- package/lib/types/workspace-route.d.ts +6 -0
- package/lib/types/workspace-route.d.ts.map +1 -1
- package/package.json +21 -21
- package/lib/drama/skills/short-drama-assets/assets/character-look.example.jsonl +0 -3
- package/lib/drama/skills/short-drama-assets/assets/continuity.example.jsonl +0 -4
- package/lib/drama/skills/short-drama-assets/assets/decisions.example.jsonl +0 -5
- package/lib/drama/skills/short-drama-assets/assets/location-view.example.jsonl +0 -3
- package/lib/drama/skills/short-drama-assets/assets/occurrences.example.jsonl +0 -5
- package/lib/drama/skills/short-drama-assets/assets/prop-state.example.jsonl +0 -3
- package/lib/drama/skills/short-drama-assets/examples/minimal/characters.jsonl +0 -2
- package/lib/drama/skills/short-drama-assets/examples/minimal/looks.jsonl +0 -2
- package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +0 -267
- package/lib/drama/skills/short-drama-assets/scripts/selftest.py +0 -89
- package/lib/drama/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +0 -2
- package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +0 -443
- package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +0 -95
- package/lib/drama/skills/short-drama-review/assets/finding-template.jsonl +0 -2
- package/lib/drama/skills/short-drama-review/assets/supersession-decision.example.json +0 -42
- package/lib/drama/skills/short-drama-review/assets/verdict-template.json +0 -32
- package/lib/drama/skills/short-drama-review/examples/minimal-findings.jsonl +0 -2
- package/lib/drama/skills/short-drama-review/examples/minimal-verdict.json +0 -34
- package/lib/drama/skills/short-drama-review/scripts/review_check.py +0 -325
- package/lib/drama/skills/short-drama-review/scripts/selftest.py +0 -87
- package/lib/drama/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +0 -2
- package/lib/drama/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +0 -12
- package/lib/drama/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +0 -2
- package/lib/drama/skills/short-drama-storyboard/scripts/selftest.py +0 -191
- package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +0 -824
- package/lib/drama/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +0 -21
- package/lib/drama/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +0 -2
- package/lib/drama/skills/short-drama-video-prompts/assets/performance.fragment.json +0 -38
- package/lib/drama/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +0 -2
- package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +0 -329
- package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +0 -451
- package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +0 -327
- package/lib/drama/skills/short-drama-video-prompts/scripts/selftest.py +0 -82
- package/lib/drama/skills/short-drama-write/scripts/voice_sheet_check.py +0 -416
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Validate provider-neutral, timeline-level short-drama music specs."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import json
|
|
8
|
-
import math
|
|
9
|
-
import re
|
|
10
|
-
import sys
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any, NamedTuple
|
|
13
|
-
|
|
14
|
-
MINIMUM_PYTHON = (3, 9)
|
|
15
|
-
if sys.version_info < MINIMUM_PYTHON:
|
|
16
|
-
raise SystemExit("music_spec_check.py requires Python 3.9 or newer")
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
# ---------------------------------------------------------------------------
|
|
20
|
-
# REFERENCE RESOLVER
|
|
21
|
-
#
|
|
22
|
-
# Each skill carries its own copy of this block. A skill must stay runnable
|
|
23
|
-
# after copying only its own directory, so the suite has no shared library and
|
|
24
|
-
# this file imports nothing from outside its own skill.
|
|
25
|
-
# ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
SOURCES_RECORD_TYPE = "sources"
|
|
28
|
-
SOURCES_SCHEMA_VERSION = "1.0.0"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class ResolvedRef(NamedTuple):
|
|
32
|
-
"""An upstream reference with its snapshot resolved, whichever form it used."""
|
|
33
|
-
|
|
34
|
-
owner: str
|
|
35
|
-
artifact: str
|
|
36
|
-
record_id: str | None
|
|
37
|
-
field: str | None
|
|
38
|
-
authority: str | None
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
class RefFinding(NamedTuple):
|
|
42
|
-
"""A structural defect in a reference object."""
|
|
43
|
-
|
|
44
|
-
code: str
|
|
45
|
-
location: str
|
|
46
|
-
detail: str
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def load_sources(document: Any) -> dict[str, dict[str, Any]]:
|
|
50
|
-
"""Return the ``sources`` declaration of a parsed file, or ``{}`` if absent.
|
|
51
|
-
|
|
52
|
-
Accepts a parsed ``.json`` document (a dict) or the parsed record list of a
|
|
53
|
-
``.jsonl`` file, whose declaration lives on the first record.
|
|
54
|
-
"""
|
|
55
|
-
if isinstance(document, list):
|
|
56
|
-
document = document[0] if document else None
|
|
57
|
-
if not isinstance(document, dict):
|
|
58
|
-
return {}
|
|
59
|
-
declared = document.get("sources")
|
|
60
|
-
if not isinstance(declared, dict):
|
|
61
|
-
return {}
|
|
62
|
-
return {key: value for key, value in declared.items() if isinstance(value, dict)}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def resolve_ref(
|
|
66
|
-
ref: Any, sources: dict[str, dict[str, Any]], location: str
|
|
67
|
-
) -> tuple[ResolvedRef | None, RefFinding | None]:
|
|
68
|
-
"""Resolve a reference object written in either the compact or expanded form."""
|
|
69
|
-
if not isinstance(ref, dict):
|
|
70
|
-
return None, RefFinding("REF_IS_NOT_AN_OBJECT", location, f"got {type(ref).__name__}")
|
|
71
|
-
src = ref.get("src")
|
|
72
|
-
if isinstance(src, str):
|
|
73
|
-
entry = sources.get(src)
|
|
74
|
-
if entry is None:
|
|
75
|
-
return None, RefFinding(
|
|
76
|
-
"REF_SRC_IS_NOT_DECLARED", location, f"src {src!r} has no sources entry"
|
|
77
|
-
)
|
|
78
|
-
owner, artifact = entry.get("owner"), entry.get("artifact")
|
|
79
|
-
if not (isinstance(owner, str) and isinstance(artifact, str)):
|
|
80
|
-
return None, RefFinding(
|
|
81
|
-
"SOURCE_ENTRY_IS_INCOMPLETE",
|
|
82
|
-
location,
|
|
83
|
-
f"sources[{src!r}] needs owner/artifact",
|
|
84
|
-
)
|
|
85
|
-
elif all(isinstance(ref.get(key), str) for key in ("owner", "artifact")):
|
|
86
|
-
owner, artifact = ref["owner"], ref["artifact"]
|
|
87
|
-
else:
|
|
88
|
-
return None, RefFinding(
|
|
89
|
-
"REF_HAS_NO_UPSTREAM_BINDING", location, "needs src, or owner+artifact"
|
|
90
|
-
)
|
|
91
|
-
optional = {
|
|
92
|
-
key: ref[key]
|
|
93
|
-
for key in ("record_id", "field", "authority")
|
|
94
|
-
if isinstance(ref.get(key), str)
|
|
95
|
-
}
|
|
96
|
-
return (
|
|
97
|
-
ResolvedRef(
|
|
98
|
-
owner,
|
|
99
|
-
artifact,
|
|
100
|
-
optional.get("record_id"),
|
|
101
|
-
optional.get("field"),
|
|
102
|
-
optional.get("authority"),
|
|
103
|
-
),
|
|
104
|
-
None,
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# ---------------------------------------------------------------------------
|
|
109
|
-
# END REFERENCE RESOLVER
|
|
110
|
-
# ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
113
|
-
HASH_RE = re.compile(r"[0-9a-f]{64}")
|
|
114
|
-
VENDOR_FIELDS = {
|
|
115
|
-
"provider",
|
|
116
|
-
"model",
|
|
117
|
-
"endpoint",
|
|
118
|
-
"api_key",
|
|
119
|
-
"task_id",
|
|
120
|
-
"remote_id",
|
|
121
|
-
"callback_url",
|
|
122
|
-
}
|
|
123
|
-
TOP_LEVEL_FIELDS = {
|
|
124
|
-
"music_id",
|
|
125
|
-
"scope",
|
|
126
|
-
"source_refs",
|
|
127
|
-
"narrative_function",
|
|
128
|
-
"prompt",
|
|
129
|
-
"mode",
|
|
130
|
-
"lyrics",
|
|
131
|
-
"mix_intent",
|
|
132
|
-
"status",
|
|
133
|
-
}
|
|
134
|
-
SCOPE_FIELDS = {"episode_id", "start_seconds", "end_seconds"}
|
|
135
|
-
REFERENCE_FIELDS = {"src", "owner", "artifact", "record_id", "field", "authority"}
|
|
136
|
-
SOURCES_HEADER_FIELDS = {"record_type", "schema_version", "sources"}
|
|
137
|
-
SOURCE_ENTRY_FIELDS = {"owner", "artifact"}
|
|
138
|
-
MIX_FIELDS = {"entry", "exit", "duck_under_dialogue", "loop"}
|
|
139
|
-
MUSIC_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}")
|
|
140
|
-
EPISODE_ID_RE = re.compile(r"EP(?:[0-9]{3}|[1-9][0-9]{3,})")
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
class ValidationError(ValueError):
|
|
144
|
-
"""A music spec cannot be handed to production safely."""
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def resolve_input(value: str | Path) -> Path:
|
|
148
|
-
path = Path(value).expanduser()
|
|
149
|
-
if path.exists() or path.is_absolute():
|
|
150
|
-
return path
|
|
151
|
-
return SKILL_ROOT / path
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def load_jsonl(value: str | Path) -> list[dict[str, Any]]:
|
|
155
|
-
path = resolve_input(value)
|
|
156
|
-
records: list[dict[str, Any]] = []
|
|
157
|
-
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
158
|
-
if not line.strip():
|
|
159
|
-
continue
|
|
160
|
-
try:
|
|
161
|
-
record = json.loads(line)
|
|
162
|
-
except json.JSONDecodeError as exc:
|
|
163
|
-
raise ValidationError(f"{path}:{number}: invalid JSON") from exc
|
|
164
|
-
if not isinstance(record, dict):
|
|
165
|
-
raise ValidationError(f"{path}:{number}: each record must be an object")
|
|
166
|
-
records.append(record)
|
|
167
|
-
if not records:
|
|
168
|
-
raise ValidationError(f"{path}: no music specs")
|
|
169
|
-
return records
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _text(value: object, label: str, *, maximum: int = 10_000) -> str:
|
|
173
|
-
if not isinstance(value, str) or not value.strip() or len(value) > maximum:
|
|
174
|
-
raise ValidationError(f"{label} must be non-empty text up to {maximum} characters")
|
|
175
|
-
return value
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _number(value: object, label: str) -> float:
|
|
179
|
-
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
180
|
-
raise ValidationError(f"{label} must be a number")
|
|
181
|
-
number = float(value)
|
|
182
|
-
if not math.isfinite(number):
|
|
183
|
-
raise ValidationError(f"{label} must be finite")
|
|
184
|
-
return number
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _exact_fields(value: dict[str, Any], allowed: set[str], label: str) -> None:
|
|
188
|
-
unknown = sorted(set(value) - allowed)
|
|
189
|
-
if unknown:
|
|
190
|
-
raise ValidationError(f"{label} has unsupported fields: {', '.join(unknown)}")
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _reject_vendor_fields(value: object, label: str) -> None:
|
|
194
|
-
if isinstance(value, dict):
|
|
195
|
-
leaked = sorted(str(key) for key in value if str(key).casefold() in VENDOR_FIELDS)
|
|
196
|
-
if leaked:
|
|
197
|
-
raise ValidationError(f"{label}: provider execution fields are forbidden: {', '.join(leaked)}")
|
|
198
|
-
for key, child in value.items():
|
|
199
|
-
_reject_vendor_fields(child, f"{label}.{key}")
|
|
200
|
-
elif isinstance(value, list):
|
|
201
|
-
for index, child in enumerate(value):
|
|
202
|
-
_reject_vendor_fields(child, f"{label}[{index}]")
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _validate_ref(value: object, label: str, sources: dict[str, dict[str, Any]]) -> None:
|
|
206
|
-
if not isinstance(value, dict):
|
|
207
|
-
raise ValidationError(f"{label} must be a reference object")
|
|
208
|
-
_exact_fields(value, REFERENCE_FIELDS, label)
|
|
209
|
-
resolved, finding = resolve_ref(value, sources, label)
|
|
210
|
-
if finding is not None:
|
|
211
|
-
raise ValidationError(f"{finding.location}: {finding.code}: {finding.detail}")
|
|
212
|
-
if resolved is None:
|
|
213
|
-
raise ValidationError(f"{label} could not be resolved")
|
|
214
|
-
for key, text in (
|
|
215
|
-
("owner", resolved.owner),
|
|
216
|
-
("artifact", resolved.artifact),
|
|
217
|
-
):
|
|
218
|
-
_text(text, f"{label}.{key}")
|
|
219
|
-
if not isinstance(value.get("record_id") or value.get("field"), str):
|
|
220
|
-
raise ValidationError(f"{label} needs record_id or field")
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def split_sources_header(
|
|
224
|
-
records: list[dict[str, Any]],
|
|
225
|
-
) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]:
|
|
226
|
-
"""Split a leading ``sources`` header off the file's own records.
|
|
227
|
-
|
|
228
|
-
The header declares each upstream snapshot once, so a reference only names
|
|
229
|
-
the snapshot key and the record it points at.
|
|
230
|
-
"""
|
|
231
|
-
if not records or records[0].get("record_type") != SOURCES_RECORD_TYPE:
|
|
232
|
-
return {}, list(records)
|
|
233
|
-
header, *specs = records
|
|
234
|
-
_exact_fields(header, SOURCES_HEADER_FIELDS, "sources header")
|
|
235
|
-
if header.get("schema_version") != SOURCES_SCHEMA_VERSION:
|
|
236
|
-
raise ValidationError(f"sources header schema_version must be {SOURCES_SCHEMA_VERSION}")
|
|
237
|
-
declared = header.get("sources")
|
|
238
|
-
if not isinstance(declared, dict) or not declared:
|
|
239
|
-
raise ValidationError("sources header must declare at least one source")
|
|
240
|
-
for key, entry in declared.items():
|
|
241
|
-
label = f"sources[{key!r}]"
|
|
242
|
-
if not isinstance(entry, dict):
|
|
243
|
-
raise ValidationError(f"{label} must be an object")
|
|
244
|
-
_exact_fields(entry, SOURCE_ENTRY_FIELDS, label)
|
|
245
|
-
for field in sorted(SOURCE_ENTRY_FIELDS):
|
|
246
|
-
_text(entry.get(field), f"{label}.{field}")
|
|
247
|
-
return load_sources(header), specs
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def validate_records(records: list[dict[str, Any]]) -> dict[str, Any]:
|
|
251
|
-
sources, specs = split_sources_header(records)
|
|
252
|
-
if not specs:
|
|
253
|
-
raise ValidationError("no music specs")
|
|
254
|
-
identifiers: set[str] = set()
|
|
255
|
-
for index, record in enumerate(specs, 1):
|
|
256
|
-
label = f"music[{index}]"
|
|
257
|
-
_reject_vendor_fields(record, label)
|
|
258
|
-
_exact_fields(record, TOP_LEVEL_FIELDS, label)
|
|
259
|
-
music_id = _text(record.get("music_id"), f"{label}.music_id", maximum=80)
|
|
260
|
-
if MUSIC_ID_RE.fullmatch(music_id) is None:
|
|
261
|
-
raise ValidationError(f"{label}.music_id must be a portable identifier")
|
|
262
|
-
if music_id in identifiers:
|
|
263
|
-
raise ValidationError(f"duplicate music_id: {music_id}")
|
|
264
|
-
identifiers.add(music_id)
|
|
265
|
-
|
|
266
|
-
scope = record.get("scope")
|
|
267
|
-
if not isinstance(scope, dict):
|
|
268
|
-
raise ValidationError(f"{label}.scope must be an object")
|
|
269
|
-
_exact_fields(scope, SCOPE_FIELDS, f"{label}.scope")
|
|
270
|
-
episode_id = _text(
|
|
271
|
-
scope.get("episode_id"), f"{label}.scope.episode_id", maximum=40
|
|
272
|
-
)
|
|
273
|
-
if EPISODE_ID_RE.fullmatch(episode_id) is None:
|
|
274
|
-
raise ValidationError(f"{label}.scope.episode_id is invalid")
|
|
275
|
-
start = _number(scope.get("start_seconds"), f"{label}.scope.start_seconds")
|
|
276
|
-
end = _number(scope.get("end_seconds"), f"{label}.scope.end_seconds")
|
|
277
|
-
if start < 0 or end <= start:
|
|
278
|
-
raise ValidationError(f"{label}.scope must have 0 <= start < end")
|
|
279
|
-
|
|
280
|
-
refs = record.get("source_refs")
|
|
281
|
-
if not isinstance(refs, list) or not refs:
|
|
282
|
-
raise ValidationError(f"{label}.source_refs must be a non-empty list")
|
|
283
|
-
for ref_index, ref in enumerate(refs, 1):
|
|
284
|
-
_validate_ref(ref, f"{label}.source_refs[{ref_index}]", sources)
|
|
285
|
-
|
|
286
|
-
_text(record.get("narrative_function"), f"{label}.narrative_function")
|
|
287
|
-
_text(record.get("prompt"), f"{label}.prompt", maximum=2_000)
|
|
288
|
-
mode = record.get("mode")
|
|
289
|
-
if mode not in {"instrumental", "song"}:
|
|
290
|
-
raise ValidationError(f"{label}.mode must be instrumental or song")
|
|
291
|
-
lyrics = record.get("lyrics")
|
|
292
|
-
if mode == "instrumental" and lyrics not in {None, ""}:
|
|
293
|
-
raise ValidationError(f"{label}: instrumental music must not carry lyrics")
|
|
294
|
-
if mode == "song":
|
|
295
|
-
_text(lyrics, f"{label}.lyrics", maximum=3_500)
|
|
296
|
-
|
|
297
|
-
mix = record.get("mix_intent")
|
|
298
|
-
if not isinstance(mix, dict):
|
|
299
|
-
raise ValidationError(f"{label}.mix_intent must be an object")
|
|
300
|
-
_exact_fields(mix, MIX_FIELDS, f"{label}.mix_intent")
|
|
301
|
-
if not isinstance(mix.get("duck_under_dialogue"), bool):
|
|
302
|
-
raise ValidationError(f"{label}.mix_intent.duck_under_dialogue must be boolean")
|
|
303
|
-
if not isinstance(mix.get("loop"), bool):
|
|
304
|
-
raise ValidationError(f"{label}.mix_intent.loop must be boolean")
|
|
305
|
-
_text(mix.get("entry"), f"{label}.mix_intent.entry")
|
|
306
|
-
_text(mix.get("exit"), f"{label}.mix_intent.exit")
|
|
307
|
-
if record.get("status") not in {"candidate", "accepted", "revise"}:
|
|
308
|
-
raise ValidationError(f"{label}.status must be candidate, accepted, or revise")
|
|
309
|
-
|
|
310
|
-
return {"status": "valid", "music_specs": len(specs)}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def main(argv: list[str] | None = None) -> int:
|
|
314
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
315
|
-
parser.add_argument("specs")
|
|
316
|
-
args = parser.parse_args(argv)
|
|
317
|
-
try:
|
|
318
|
-
result = validate_records(load_jsonl(args.specs))
|
|
319
|
-
except (OSError, UnicodeError, ValidationError) as exc:
|
|
320
|
-
print(str(exc), file=sys.stderr)
|
|
321
|
-
return 2
|
|
322
|
-
print(json.dumps(result, ensure_ascii=True, sort_keys=True))
|
|
323
|
-
return 0
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if __name__ == "__main__":
|
|
327
|
-
raise SystemExit(main())
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Offline self-test for standalone video and music prompt tooling."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import copy
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
from music_spec_check import SKILL_ROOT, ValidationError, load_jsonl, validate_records
|
|
10
|
-
|
|
11
|
-
MINIMUM_PYTHON = (3, 9)
|
|
12
|
-
if sys.version_info < MINIMUM_PYTHON:
|
|
13
|
-
raise SystemExit("selftest.py requires Python 3.9 or newer")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def require(condition: bool, message: str) -> None:
|
|
17
|
-
if not condition:
|
|
18
|
-
raise AssertionError(message)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def fail(records: list[dict], marker: str) -> None:
|
|
22
|
-
try:
|
|
23
|
-
validate_records(records)
|
|
24
|
-
except ValidationError as exc:
|
|
25
|
-
require(marker in str(exc), f"expected {marker!r}, got {exc!s}")
|
|
26
|
-
else:
|
|
27
|
-
raise AssertionError(f"expected failure containing {marker!r}")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def main() -> int:
|
|
31
|
-
records = load_jsonl(SKILL_ROOT / "examples/minimal-music-specs.jsonl")
|
|
32
|
-
require(validate_records(records)["music_specs"] == 1, "valid fixture count")
|
|
33
|
-
header, spec = records[0], records[1]
|
|
34
|
-
require(spec["source_refs"][0]["src"] == "screenplay", "fixture uses a source key")
|
|
35
|
-
|
|
36
|
-
# A file that was written before sources declarations existed still carries
|
|
37
|
-
# the snapshot on every reference, and still validates.
|
|
38
|
-
expanded = copy.deepcopy(spec)
|
|
39
|
-
expanded["source_refs"] = [
|
|
40
|
-
{**header["sources"]["screenplay"], "record_id": "EP001-SC001"}
|
|
41
|
-
]
|
|
42
|
-
require(validate_records([expanded])["music_specs"] == 1, "inline snapshot accepted")
|
|
43
|
-
|
|
44
|
-
undeclared = copy.deepcopy(spec)
|
|
45
|
-
undeclared["source_refs"] = [{"src": "screenplai", "record_id": "EP001-SC001"}]
|
|
46
|
-
fail([header, undeclared], "REF_SRC_IS_NOT_DECLARED")
|
|
47
|
-
|
|
48
|
-
unbound = copy.deepcopy(spec)
|
|
49
|
-
unbound["source_refs"] = [{"record_id": "EP001-SC001"}]
|
|
50
|
-
fail([header, unbound], "REF_HAS_NO_UPSTREAM_BINDING")
|
|
51
|
-
|
|
52
|
-
duplicate = [header, spec, copy.deepcopy(spec)]
|
|
53
|
-
fail(duplicate, "duplicate music_id")
|
|
54
|
-
|
|
55
|
-
leaked = [header, copy.deepcopy(spec)]
|
|
56
|
-
leaked[1]["model"] = "example"
|
|
57
|
-
fail(leaked, "provider execution fields")
|
|
58
|
-
|
|
59
|
-
song_without_lyrics = [header, copy.deepcopy(spec)]
|
|
60
|
-
song_without_lyrics[1]["mode"] = "song"
|
|
61
|
-
fail(song_without_lyrics, "lyrics")
|
|
62
|
-
|
|
63
|
-
invalid_scope = [header, copy.deepcopy(spec)]
|
|
64
|
-
invalid_scope[1]["scope"]["end_seconds"] = 0
|
|
65
|
-
fail(invalid_scope, "0 <= start < end")
|
|
66
|
-
|
|
67
|
-
unsupported = [header, copy.deepcopy(spec)]
|
|
68
|
-
unsupported[1]["token"] = "not provider-neutral"
|
|
69
|
-
fail(unsupported, "unsupported fields")
|
|
70
|
-
|
|
71
|
-
# A source entry now names an upstream artifact and nothing else; a stray
|
|
72
|
-
# byte digest is an unsupported field rather than a malformed hash.
|
|
73
|
-
stale_source = [copy.deepcopy(header), spec]
|
|
74
|
-
stale_source[0]["sources"]["screenplay"]["hash"] = "not-a-sha256"
|
|
75
|
-
fail(stale_source, "unsupported fields")
|
|
76
|
-
|
|
77
|
-
print("10 self-tests passed")
|
|
78
|
-
return 0
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if __name__ == "__main__":
|
|
82
|
-
raise SystemExit(main())
|