plotagon-director 0.6.0__py3-none-any.whl
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.
- plotagon_director/__init__.py +3 -0
- plotagon_director/audio/__init__.py +0 -0
- plotagon_director/audio/phonemes.py +70 -0
- plotagon_director/audio/tts.py +163 -0
- plotagon_director/cli.py +308 -0
- plotagon_director/core/__init__.py +0 -0
- plotagon_director/core/archive.py +35 -0
- plotagon_director/core/decompiler.py +120 -0
- plotagon_director/core/diffplot.py +89 -0
- plotagon_director/core/doctor.py +67 -0
- plotagon_director/core/plotdoc.py +178 -0
- plotagon_director/core/validator.py +165 -0
- plotagon_director/library/__init__.py +14 -0
- plotagon_director/library/builder.py +153 -0
- plotagon_director/library/data/library.json +4819 -0
- plotagon_director-0.6.0.dist-info/METADATA +143 -0
- plotagon_director-0.6.0.dist-info/RECORD +21 -0
- plotagon_director-0.6.0.dist-info/WHEEL +5 -0
- plotagon_director-0.6.0.dist-info/entry_points.txt +2 -0
- plotagon_director-0.6.0.dist-info/licenses/LICENSE +21 -0
- plotagon_director-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Decompilation d'un .plotdoc vers le format project.yaml.
|
|
2
|
+
|
|
3
|
+
Chemin inverse du generateur : permet d'editer au texte un film cree dans
|
|
4
|
+
l'appli, puis de le regenerer. Les types d'instructions inconnus sont
|
|
5
|
+
conserves bruts dans `unknown_instructions` (rien n'est perdu).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _slug(text):
|
|
12
|
+
s = re.sub(r"[^a-z0-9]+", "", str(text).lower())
|
|
13
|
+
return s or "actor"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ref_for(character_id, display_name, cast):
|
|
17
|
+
"""Retourne un ref stable pour un personnage (meme id -> meme ref)."""
|
|
18
|
+
for ref, member in cast.items():
|
|
19
|
+
if member["character"] == character_id:
|
|
20
|
+
return ref
|
|
21
|
+
base = _slug(display_name or character_id.split(".")[-1])
|
|
22
|
+
ref = base
|
|
23
|
+
n = 2
|
|
24
|
+
while ref in cast:
|
|
25
|
+
ref = "%s%d" % (base, n)
|
|
26
|
+
n += 1
|
|
27
|
+
cast[ref] = {
|
|
28
|
+
"ref": ref,
|
|
29
|
+
"character": character_id,
|
|
30
|
+
"name": display_name or character_id.split(".")[-1].capitalize(),
|
|
31
|
+
}
|
|
32
|
+
return ref
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plotdoc_to_project(plotdoc):
|
|
36
|
+
"""Transforme un dict .plotdoc en dict project.yaml.
|
|
37
|
+
|
|
38
|
+
Retourne (project, unknown_count).
|
|
39
|
+
"""
|
|
40
|
+
cast = {}
|
|
41
|
+
scenes = []
|
|
42
|
+
unknown = []
|
|
43
|
+
current = None
|
|
44
|
+
|
|
45
|
+
for instr in plotdoc.get("contents", {}).get("instructions", []):
|
|
46
|
+
itype = instr.get("type")
|
|
47
|
+
p = instr.get("parameters", {})
|
|
48
|
+
|
|
49
|
+
if itype == "scene":
|
|
50
|
+
current = {"scene": p.get("scene", {}).get("id", "")}
|
|
51
|
+
name = p.get("scene", {}).get("text")
|
|
52
|
+
if name:
|
|
53
|
+
current["scene_name"] = name
|
|
54
|
+
current["actors"] = []
|
|
55
|
+
current["dialogue"] = []
|
|
56
|
+
i = 1
|
|
57
|
+
while ("actor%d" % i) in p:
|
|
58
|
+
actor = p["actor%d" % i]
|
|
59
|
+
ref = _ref_for(actor.get("id", ""), actor.get("text"), cast)
|
|
60
|
+
entry = {"ref": ref}
|
|
61
|
+
position = p.get("location%d" % i, {}).get("id")
|
|
62
|
+
if position:
|
|
63
|
+
entry["position"] = position
|
|
64
|
+
current["actors"].append(entry)
|
|
65
|
+
i += 1
|
|
66
|
+
scenes.append(current)
|
|
67
|
+
|
|
68
|
+
elif itype == "dialogue":
|
|
69
|
+
if current is None:
|
|
70
|
+
current = {"scene": "", "actors": [], "dialogue": []}
|
|
71
|
+
scenes.append(current)
|
|
72
|
+
character = p.get("character", {})
|
|
73
|
+
ref = _ref_for(character.get("id", ""), character.get("text"), cast)
|
|
74
|
+
line = {
|
|
75
|
+
"actor": ref,
|
|
76
|
+
"expression": p.get("expression", {}).get("id", "neutral"),
|
|
77
|
+
"text": p.get("text", {}).get("text", ""),
|
|
78
|
+
}
|
|
79
|
+
if p.get("isRecorded"):
|
|
80
|
+
# replique avec audio embarque dans le .plot d'origine ;
|
|
81
|
+
# ajouter `voice:` au cast pour la re-synthetiser au build
|
|
82
|
+
line["recorded"] = True
|
|
83
|
+
camera = p.get("camera")
|
|
84
|
+
if camera:
|
|
85
|
+
line["camera"] = camera.get("type", {}).get("name", "")
|
|
86
|
+
if camera.get("position"):
|
|
87
|
+
line["camera_position"] = camera["position"].get("id", "")
|
|
88
|
+
if camera.get("target"):
|
|
89
|
+
line["camera_target"] = camera["target"].get("id", "")
|
|
90
|
+
current["dialogue"].append(line)
|
|
91
|
+
|
|
92
|
+
elif itype == "music":
|
|
93
|
+
if current is not None:
|
|
94
|
+
current["music"] = p.get("music", {}).get("id", "")
|
|
95
|
+
|
|
96
|
+
elif itype == "action":
|
|
97
|
+
if current is None:
|
|
98
|
+
current = {"scene": "", "actors": [], "dialogue": []}
|
|
99
|
+
scenes.append(current)
|
|
100
|
+
character = p.get("character", {})
|
|
101
|
+
ref = _ref_for(character.get("id", ""), character.get("text"), cast)
|
|
102
|
+
line = {"actor": ref, "action": p.get("action", {}).get("id", "")}
|
|
103
|
+
target = p.get("target")
|
|
104
|
+
if target:
|
|
105
|
+
line["target"] = _ref_for(
|
|
106
|
+
target.get("id", ""), target.get("text"), cast
|
|
107
|
+
)
|
|
108
|
+
current["dialogue"].append(line)
|
|
109
|
+
|
|
110
|
+
else:
|
|
111
|
+
unknown.append(instr)
|
|
112
|
+
|
|
113
|
+
project = {
|
|
114
|
+
"project": {"title": plotdoc.get("name", "untitled")},
|
|
115
|
+
"cast": list(cast.values()),
|
|
116
|
+
"scenes": scenes,
|
|
117
|
+
}
|
|
118
|
+
if unknown:
|
|
119
|
+
project["unknown_instructions"] = unknown
|
|
120
|
+
return project, len(unknown)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Comparaison structurelle de deux .plot — outil de retro-ingenierie.
|
|
2
|
+
|
|
3
|
+
Usage type : exporter un projet avant/apres l'ajout d'une fonctionnalite
|
|
4
|
+
inconnue (musique, camera, 2e acteur...) dans l'appli officielle, puis
|
|
5
|
+
comparer pour isoler les nouveaux types d'instructions, cles JSON et
|
|
6
|
+
fichiers embarques.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import zipfile
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
from .archive import read_plot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _entry_kinds(path):
|
|
16
|
+
kinds = Counter()
|
|
17
|
+
names = []
|
|
18
|
+
with zipfile.ZipFile(path) as zf:
|
|
19
|
+
for name in zf.namelist():
|
|
20
|
+
suffix = "." + name.rsplit(".", 1)[-1] if "." in name else "(sans ext)"
|
|
21
|
+
kinds[suffix] += 1
|
|
22
|
+
names.append(name)
|
|
23
|
+
return kinds, names
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _param_keys_by_type(plotdoc):
|
|
27
|
+
keys = {}
|
|
28
|
+
for instr in plotdoc.get("contents", {}).get("instructions", []):
|
|
29
|
+
keys.setdefault(instr.get("type"), set()).update(
|
|
30
|
+
instr.get("parameters", {}).keys()
|
|
31
|
+
)
|
|
32
|
+
return keys
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def diff_plots(path_a, path_b):
|
|
36
|
+
"""Retourne un rapport texte des differences structurelles A -> B."""
|
|
37
|
+
lines = []
|
|
38
|
+
doc_a, doc_b = read_plot(path_a), read_plot(path_b)
|
|
39
|
+
kinds_a, _ = _entry_kinds(path_a)
|
|
40
|
+
kinds_b, _ = _entry_kinds(path_b)
|
|
41
|
+
|
|
42
|
+
lines.append("FICHIERS EMBARQUES (extension: A -> B)")
|
|
43
|
+
for suffix in sorted(set(kinds_a) | set(kinds_b)):
|
|
44
|
+
a, b = kinds_a.get(suffix, 0), kinds_b.get(suffix, 0)
|
|
45
|
+
marker = " " if a == b else "->"
|
|
46
|
+
lines.append(" %s %-12s %d -> %d" % (marker, suffix, a, b))
|
|
47
|
+
|
|
48
|
+
lines.append("")
|
|
49
|
+
lines.append("CLES RACINE DU .plotdoc")
|
|
50
|
+
root_a, root_b = set(doc_a), set(doc_b)
|
|
51
|
+
for key in sorted(root_b - root_a):
|
|
52
|
+
lines.append(" + %s (nouveau dans B)" % key)
|
|
53
|
+
for key in sorted(root_a - root_b):
|
|
54
|
+
lines.append(" - %s (absent de B)" % key)
|
|
55
|
+
if root_a == root_b:
|
|
56
|
+
lines.append(" identiques")
|
|
57
|
+
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("TYPES D'INSTRUCTIONS ET PARAMETRES")
|
|
60
|
+
keys_a = _param_keys_by_type(doc_a)
|
|
61
|
+
keys_b = _param_keys_by_type(doc_b)
|
|
62
|
+
for itype in sorted(set(keys_a) | set(keys_b)):
|
|
63
|
+
in_a, in_b = keys_a.get(itype), keys_b.get(itype)
|
|
64
|
+
if in_a is None:
|
|
65
|
+
lines.append(" + type '%s' NOUVEAU dans B, parametres: %s"
|
|
66
|
+
% (itype, ", ".join(sorted(in_b))))
|
|
67
|
+
elif in_b is None:
|
|
68
|
+
lines.append(" - type '%s' absent de B" % itype)
|
|
69
|
+
else:
|
|
70
|
+
new = in_b - in_a
|
|
71
|
+
gone = in_a - in_b
|
|
72
|
+
if new:
|
|
73
|
+
lines.append(" ~ type '%s': nouveaux parametres dans B: %s"
|
|
74
|
+
% (itype, ", ".join(sorted(new))))
|
|
75
|
+
if gone:
|
|
76
|
+
lines.append(" ~ type '%s': parametres absents de B: %s"
|
|
77
|
+
% (itype, ", ".join(sorted(gone))))
|
|
78
|
+
if not new and not gone:
|
|
79
|
+
lines.append(" type '%s': parametres identiques" % itype)
|
|
80
|
+
|
|
81
|
+
counts_a = Counter(i["type"] for i in doc_a["contents"]["instructions"])
|
|
82
|
+
counts_b = Counter(i["type"] for i in doc_b["contents"]["instructions"])
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append("NOMBRE D'INSTRUCTIONS PAR TYPE (A -> B)")
|
|
85
|
+
for itype in sorted(set(counts_a) | set(counts_b)):
|
|
86
|
+
lines.append(" %-12s %d -> %d"
|
|
87
|
+
% (itype, counts_a.get(itype, 0), counts_b.get(itype, 0)))
|
|
88
|
+
|
|
89
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Diagnostic d'environnement : dependances, bibliotheque, installation, TLS."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from ..library import DATA_PATH, load_library
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run_doctor(check_network=True):
|
|
10
|
+
"""Retourne (rapport_texte, ok_global)."""
|
|
11
|
+
lines = []
|
|
12
|
+
ok = True
|
|
13
|
+
|
|
14
|
+
def check(label, passed, detail=""):
|
|
15
|
+
nonlocal ok
|
|
16
|
+
ok = ok and passed
|
|
17
|
+
lines.append("%s %-38s %s" % ("OK " if passed else "FAIL", label, detail))
|
|
18
|
+
|
|
19
|
+
check("Python >= 3.10", sys.version_info >= (3, 10),
|
|
20
|
+
"%d.%d" % sys.version_info[:2])
|
|
21
|
+
|
|
22
|
+
for module in ("yaml", "edge_tts", "soundfile", "numpy", "truststore"):
|
|
23
|
+
try:
|
|
24
|
+
importlib.import_module(module)
|
|
25
|
+
check("module %s" % module, True)
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
check("module %s" % module, False, str(exc))
|
|
28
|
+
|
|
29
|
+
library = load_library()
|
|
30
|
+
if library:
|
|
31
|
+
check("bibliotheque (library.json)", True,
|
|
32
|
+
"%d scenes, %d personnages, %d expressions" % (
|
|
33
|
+
len(library["scenes"]), len(library["characters"]),
|
|
34
|
+
len(library["expressions"])))
|
|
35
|
+
else:
|
|
36
|
+
check("bibliotheque (library.json)", False,
|
|
37
|
+
"lancer: python -m plotagon_director.library.builder (%s)" % DATA_PATH)
|
|
38
|
+
|
|
39
|
+
# optionnelle: necessaire uniquement pour RECONSTRUIRE la bibliotheque
|
|
40
|
+
from ..library.builder import DEFAULT_MANIFEST
|
|
41
|
+
check("installation Plotagon (optionnelle)", True,
|
|
42
|
+
str(DEFAULT_MANIFEST) if DEFAULT_MANIFEST.exists()
|
|
43
|
+
else "non trouvee — le generateur fonctionne sans (bibliotheque embarquee)")
|
|
44
|
+
|
|
45
|
+
if check_network:
|
|
46
|
+
try:
|
|
47
|
+
import ssl
|
|
48
|
+
import socket
|
|
49
|
+
try:
|
|
50
|
+
import truststore
|
|
51
|
+
truststore.inject_into_ssl()
|
|
52
|
+
except ImportError:
|
|
53
|
+
pass
|
|
54
|
+
ctx = ssl.create_default_context()
|
|
55
|
+
with socket.create_connection(("speech.platform.bing.com", 443), timeout=6) as s:
|
|
56
|
+
with ctx.wrap_socket(s, server_hostname="speech.platform.bing.com"):
|
|
57
|
+
pass
|
|
58
|
+
check("TLS vers Edge TTS", True)
|
|
59
|
+
except ssl.SSLCertVerificationError as exc:
|
|
60
|
+
check("TLS vers Edge TTS", False,
|
|
61
|
+
"certificat rejete (antivirus qui intercepte ? verifier truststore): %s" % exc)
|
|
62
|
+
except OSError as exc:
|
|
63
|
+
check("TLS vers Edge TTS", False, "reseau indisponible: %s" % exc)
|
|
64
|
+
|
|
65
|
+
lines.append("")
|
|
66
|
+
lines.append("RESULT %s" % ("PASS" if ok else "FAIL"))
|
|
67
|
+
return "\n".join(lines), ok
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Construction du document .plotdoc depuis un projet YAML.
|
|
2
|
+
|
|
3
|
+
Format CONFIRME le 2026-08-31 sur Plotagon Studio 1.11.0 (exports officiels).
|
|
4
|
+
Voir docs/PLOT_FORMAT.md pour la specification complete.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
# Duree estimee par caractere de dialogue non synthetise, calibree sur
|
|
11
|
+
# l'echantillon officiel (63 caracteres -> lengthSeconds "6755").
|
|
12
|
+
MS_PER_CHAR = 107
|
|
13
|
+
|
|
14
|
+
_POSITION_TEXTS = {"Left": "on screen", "Right": "on screen"}
|
|
15
|
+
|
|
16
|
+
# Enum des types de camera — ordre releve dans Assembly-CSharp.dll et
|
|
17
|
+
# CONFIRME par un export officiel (4="moving right", 8="wide shot").
|
|
18
|
+
CAMERA_TYPES = [
|
|
19
|
+
"default", # 0
|
|
20
|
+
"establishing shot", # 1
|
|
21
|
+
"in dialogue mode", # 2
|
|
22
|
+
"in interaction mode", # 3
|
|
23
|
+
"moving right", # 4
|
|
24
|
+
"moving left", # 5
|
|
25
|
+
"moving forward", # 6
|
|
26
|
+
"moving backward", # 7
|
|
27
|
+
"wide shot", # 8
|
|
28
|
+
"still", # 9
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_CAMERA_POSITION_NAMES = {"EstablishingLocation": "Establishing Spot"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _camera_params(line, slot_texts):
|
|
35
|
+
"""Construit le parametre camera d'une instruction dialogue."""
|
|
36
|
+
name = line["camera"]
|
|
37
|
+
camera = {"type": {"id": CAMERA_TYPES.index(name), "name": name}}
|
|
38
|
+
position = line.get("camera_position")
|
|
39
|
+
if position:
|
|
40
|
+
camera["position"] = {
|
|
41
|
+
"id": position,
|
|
42
|
+
"name": _CAMERA_POSITION_NAMES.get(position, position),
|
|
43
|
+
"useLocations": True,
|
|
44
|
+
}
|
|
45
|
+
target = line.get("camera_target")
|
|
46
|
+
if target:
|
|
47
|
+
camera["target"] = {
|
|
48
|
+
"id": target,
|
|
49
|
+
"name": slot_texts.get(target, target),
|
|
50
|
+
}
|
|
51
|
+
return camera
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _display(asset_id):
|
|
55
|
+
return str(asset_id).split(".")[-1].capitalize()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _entry(asset_id, text=None):
|
|
59
|
+
return {"id": asset_id, "text": text if text is not None else _display(asset_id)}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _base_params():
|
|
63
|
+
return {
|
|
64
|
+
"GUID": str(uuid.uuid4()),
|
|
65
|
+
"extensiondata": True,
|
|
66
|
+
"isRecorded": False,
|
|
67
|
+
"playRecording": False,
|
|
68
|
+
"extrasEnabled": False,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_plotdoc(data, library=None):
|
|
73
|
+
"""Transforme un projet (dict issu de project.yaml) en dict .plotdoc.
|
|
74
|
+
|
|
75
|
+
Retourne (plotdoc, voice_jobs). voice_jobs liste les repliques a
|
|
76
|
+
synthetiser : {"guid", "text", "voice", "rate", "pitch"} — remplie quand
|
|
77
|
+
le membre du cast a un champ `voice` (id de voix Edge TTS).
|
|
78
|
+
|
|
79
|
+
library : bibliotheque (load_library()) — sert a choisir des positions
|
|
80
|
+
valides par defaut et leurs libelles, propres a chaque scene.
|
|
81
|
+
"""
|
|
82
|
+
project = data.get("project", {})
|
|
83
|
+
cast = {c["ref"]: c for c in data.get("cast", [])}
|
|
84
|
+
all_positions = (library or {}).get("scene_positions", {})
|
|
85
|
+
|
|
86
|
+
instructions = []
|
|
87
|
+
voice_jobs = []
|
|
88
|
+
total_chars = 0
|
|
89
|
+
|
|
90
|
+
for scene in data.get("scenes", []):
|
|
91
|
+
params = _base_params()
|
|
92
|
+
params["scene"] = _entry(scene["scene"], scene.get("scene_name"))
|
|
93
|
+
slots = all_positions.get(scene["scene"], [])
|
|
94
|
+
slot_texts = {s["id"]: s["text"] for s in slots}
|
|
95
|
+
for i, actor in enumerate(scene.get("actors", []), start=1):
|
|
96
|
+
member = cast[actor["ref"]]
|
|
97
|
+
position = actor.get("position")
|
|
98
|
+
if not position:
|
|
99
|
+
# par defaut: i-eme slot reel de la scene, sinon Left/Right
|
|
100
|
+
if len(slots) >= i:
|
|
101
|
+
position = slots[i - 1]["id"]
|
|
102
|
+
else:
|
|
103
|
+
position = "Left" if i == 1 else "Right"
|
|
104
|
+
text = slot_texts.get(position) or _POSITION_TEXTS.get(
|
|
105
|
+
position, "on screen"
|
|
106
|
+
)
|
|
107
|
+
params["location%d" % i] = _entry(position, text)
|
|
108
|
+
params["actor%d" % i] = _entry(
|
|
109
|
+
member["character"], member.get("name")
|
|
110
|
+
)
|
|
111
|
+
instructions.append({"type": "scene", "parameters": params})
|
|
112
|
+
|
|
113
|
+
# musique de scene — structure relevee dans app.js (PROBABLE)
|
|
114
|
+
if scene.get("music"):
|
|
115
|
+
params = _base_params()
|
|
116
|
+
params["music"] = _entry(scene["music"], scene.get("music_name"))
|
|
117
|
+
instructions.append({"type": "music", "parameters": params})
|
|
118
|
+
|
|
119
|
+
for line in scene.get("dialogue", []):
|
|
120
|
+
member = cast[line["actor"]]
|
|
121
|
+
params = _base_params()
|
|
122
|
+
|
|
123
|
+
if line.get("action"):
|
|
124
|
+
# instruction action — CONFIRMEE par export officiel:
|
|
125
|
+
# {character, target, action} ; le texte est la phrase
|
|
126
|
+
# verbale de la bibliotheque (ex. "force chokes")
|
|
127
|
+
known = (library or {}).get("actions", {}).get(line["action"], {})
|
|
128
|
+
params["action"] = _entry(
|
|
129
|
+
line["action"],
|
|
130
|
+
line.get("action_name") or known.get("phrase"),
|
|
131
|
+
)
|
|
132
|
+
params["character"] = _entry(
|
|
133
|
+
member["character"], member.get("name")
|
|
134
|
+
)
|
|
135
|
+
target_ref = line.get("target")
|
|
136
|
+
if target_ref:
|
|
137
|
+
target = cast[target_ref]
|
|
138
|
+
params["target"] = _entry(
|
|
139
|
+
target["character"], target.get("name")
|
|
140
|
+
)
|
|
141
|
+
instructions.append({"type": "action", "parameters": params})
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
expression = line.get("expression", "neutral")
|
|
145
|
+
params["expression"] = _entry(expression, expression.capitalize())
|
|
146
|
+
params["text"] = {"id": "", "text": line["text"]}
|
|
147
|
+
params["character"] = _entry(member["character"], member.get("name"))
|
|
148
|
+
if line.get("camera"):
|
|
149
|
+
params["camera"] = _camera_params(line, slot_texts)
|
|
150
|
+
voice = line.get("voice") or member.get("voice")
|
|
151
|
+
if voice:
|
|
152
|
+
params["isRecorded"] = True
|
|
153
|
+
params["playRecording"] = True
|
|
154
|
+
voice_jobs.append({
|
|
155
|
+
"guid": params["GUID"],
|
|
156
|
+
"text": line["text"],
|
|
157
|
+
"voice": voice,
|
|
158
|
+
"rate": line.get("rate") or member.get("rate"),
|
|
159
|
+
"pitch": line.get("pitch") or member.get("pitch"),
|
|
160
|
+
})
|
|
161
|
+
instructions.append({"type": "dialogue", "parameters": params})
|
|
162
|
+
total_chars += len(line["text"])
|
|
163
|
+
|
|
164
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
165
|
+
plotdoc = {
|
|
166
|
+
"id": str(uuid.uuid4()),
|
|
167
|
+
"name": project.get("title", "untitled"),
|
|
168
|
+
"dateCreated": now,
|
|
169
|
+
"dateUpdated": now,
|
|
170
|
+
"thumbnail": "",
|
|
171
|
+
"lengthSeconds": str(max(1000, total_chars * MS_PER_CHAR)),
|
|
172
|
+
"voicerecordings": [j["guid"] for j in voice_jobs],
|
|
173
|
+
"contents": {
|
|
174
|
+
"selectedIndex": max(0, len(instructions) - 1),
|
|
175
|
+
"instructions": instructions,
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
return plotdoc, voice_jobs
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Validation d'un project.yaml contre la bibliotheque de ressources."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from ..library import load_library
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_yaml(path):
|
|
11
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
12
|
+
return yaml.safe_load(f)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_project(path):
|
|
16
|
+
"""Charge un project.yaml, en fusionnant un eventuel `cast_file`.
|
|
17
|
+
|
|
18
|
+
`cast_file` (chemin relatif au project.yaml) permet de partager un cast
|
|
19
|
+
recurrent entre plusieurs films ; le cast local complete/precede.
|
|
20
|
+
"""
|
|
21
|
+
data = load_yaml(path)
|
|
22
|
+
if isinstance(data, dict) and data.get("cast_file"):
|
|
23
|
+
extra = load_yaml(Path(path).parent / data["cast_file"]) or []
|
|
24
|
+
if isinstance(extra, dict):
|
|
25
|
+
extra = extra.get("cast", [])
|
|
26
|
+
data["cast"] = list(extra) + list(data.get("cast", []))
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_project(path):
|
|
31
|
+
data = load_project(path)
|
|
32
|
+
errors, warnings = [], []
|
|
33
|
+
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
errors.append("Project root must be a mapping.")
|
|
36
|
+
return errors, warnings
|
|
37
|
+
|
|
38
|
+
library = load_library()
|
|
39
|
+
if library is None:
|
|
40
|
+
warnings.append(
|
|
41
|
+
"library.json absent: lance `python -m plotagon_director.library.builder` "
|
|
42
|
+
"pour valider contre les vraies ressources."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
project = data.get("project", {})
|
|
46
|
+
cast = data.get("cast", [])
|
|
47
|
+
scenes = data.get("scenes", [])
|
|
48
|
+
|
|
49
|
+
if not project.get("title"):
|
|
50
|
+
warnings.append("Project title is missing.")
|
|
51
|
+
if not scenes:
|
|
52
|
+
errors.append("No scenes defined.")
|
|
53
|
+
|
|
54
|
+
refs = {}
|
|
55
|
+
for member in cast:
|
|
56
|
+
if not isinstance(member, dict) or not member.get("ref"):
|
|
57
|
+
errors.append("Cast member without ref.")
|
|
58
|
+
continue
|
|
59
|
+
if member["ref"] in refs:
|
|
60
|
+
errors.append("Duplicate cast ref '%s'." % member["ref"])
|
|
61
|
+
refs[member["ref"]] = member
|
|
62
|
+
character = member.get("character")
|
|
63
|
+
if not character:
|
|
64
|
+
errors.append("Cast '%s': missing character id." % member["ref"])
|
|
65
|
+
elif library and character not in library["characters"]:
|
|
66
|
+
errors.append(
|
|
67
|
+
"Cast '%s': character '%s' absent de la bibliotheque."
|
|
68
|
+
% (member["ref"], character)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
for index, scene in enumerate(scenes, start=1):
|
|
72
|
+
sid = scene.get("scene", "?")
|
|
73
|
+
label = "Scene %d (%s)" % (index, sid)
|
|
74
|
+
|
|
75
|
+
if not scene.get("scene"):
|
|
76
|
+
errors.append("Scene %d: missing scene id." % index)
|
|
77
|
+
elif library and sid not in library["scenes"]:
|
|
78
|
+
errors.append("%s: scene absente de la bibliotheque." % label)
|
|
79
|
+
|
|
80
|
+
actors = scene.get("actors", [])
|
|
81
|
+
if not actors:
|
|
82
|
+
warnings.append("%s: no actors." % label)
|
|
83
|
+
if len(actors) > 2:
|
|
84
|
+
warnings.append(
|
|
85
|
+
"%s: %d acteurs — seuls 2 slots (actor1/actor2) sont CONFIRMES."
|
|
86
|
+
% (label, len(actors))
|
|
87
|
+
)
|
|
88
|
+
slots = (library or {}).get("scene_positions", {}).get(sid, [])
|
|
89
|
+
slot_ids = {s["id"] for s in slots}
|
|
90
|
+
if slots and len(actors) > len(slots):
|
|
91
|
+
warnings.append(
|
|
92
|
+
"%s: %d acteurs pour %d position(s) disponible(s)."
|
|
93
|
+
% (label, len(actors), len(slots))
|
|
94
|
+
)
|
|
95
|
+
for actor in actors:
|
|
96
|
+
if actor.get("ref") not in refs:
|
|
97
|
+
errors.append("%s: unknown actor ref '%s'." % (label, actor.get("ref")))
|
|
98
|
+
position = actor.get("position")
|
|
99
|
+
if position and slot_ids and position not in slot_ids:
|
|
100
|
+
errors.append(
|
|
101
|
+
"%s: position '%s' inexistante (disponibles: %s)."
|
|
102
|
+
% (label, position, ", ".join(sorted(slot_ids)))
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
music = scene.get("music")
|
|
106
|
+
if music and music != "music.stopmusic":
|
|
107
|
+
if library and music not in library["music"]:
|
|
108
|
+
errors.append(
|
|
109
|
+
"%s: musique '%s' absente de la bibliotheque." % (label, music)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for line in scene.get("dialogue", []):
|
|
113
|
+
if line.get("actor") not in refs:
|
|
114
|
+
errors.append(
|
|
115
|
+
"%s: dialogue actor '%s' is unknown." % (label, line.get("actor"))
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if line.get("action"):
|
|
119
|
+
action_id = line["action"]
|
|
120
|
+
known_actions = (library or {}).get("actions", {})
|
|
121
|
+
if known_actions and action_id not in known_actions:
|
|
122
|
+
errors.append(
|
|
123
|
+
"%s: action '%s' absente de la bibliotheque "
|
|
124
|
+
"(valides: %s)."
|
|
125
|
+
% (label, action_id, ", ".join(sorted(known_actions)))
|
|
126
|
+
)
|
|
127
|
+
target = line.get("target")
|
|
128
|
+
if target and target not in refs:
|
|
129
|
+
errors.append(
|
|
130
|
+
"%s: action target '%s' is unknown." % (label, target)
|
|
131
|
+
)
|
|
132
|
+
if not target:
|
|
133
|
+
warnings.append(
|
|
134
|
+
"%s: action '%s' sans target — les actions sont des "
|
|
135
|
+
"interactions a 2 personnages (observees avec target)."
|
|
136
|
+
% (label, action_id)
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if not line.get("text"):
|
|
141
|
+
# valide dans l'appli (mime sans parole), observe dans les
|
|
142
|
+
# exports officiels — simple avertissement
|
|
143
|
+
warnings.append("%s: dialogue sans texte (mime)." % label)
|
|
144
|
+
expression = line.get("expression", "neutral")
|
|
145
|
+
if library and expression not in library["expressions"]:
|
|
146
|
+
errors.append(
|
|
147
|
+
"%s: expression '%s' absente de la bibliotheque."
|
|
148
|
+
% (label, expression)
|
|
149
|
+
)
|
|
150
|
+
camera = line.get("camera")
|
|
151
|
+
if camera:
|
|
152
|
+
from .plotdoc import CAMERA_TYPES
|
|
153
|
+
if camera not in CAMERA_TYPES:
|
|
154
|
+
errors.append(
|
|
155
|
+
"%s: camera '%s' inconnue (valides: %s)."
|
|
156
|
+
% (label, camera, ", ".join(CAMERA_TYPES))
|
|
157
|
+
)
|
|
158
|
+
target = line.get("camera_target")
|
|
159
|
+
if target and slot_ids and target not in slot_ids:
|
|
160
|
+
warnings.append(
|
|
161
|
+
"%s: camera_target '%s' n'est pas un slot de la scene."
|
|
162
|
+
% (label, target)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return errors, warnings
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Bibliotheque des ressources Plotagon (scenes, personnages, expressions...)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DATA_PATH = Path(__file__).parent / "data" / "library.json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_library():
|
|
10
|
+
"""Retourne la bibliotheque, ou None si elle n'a pas ete construite."""
|
|
11
|
+
if DATA_PATH.exists():
|
|
12
|
+
with open(DATA_PATH, encoding="utf-8") as f:
|
|
13
|
+
return json.load(f)
|
|
14
|
+
return None
|