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.
@@ -0,0 +1,3 @@
1
+ """plotagon_director — generation de films Plotagon Studio Desktop (.plot)."""
2
+
3
+ __version__ = "0.6.0"
File without changes
@@ -0,0 +1,70 @@
1
+ """Fichiers .phonemes (synchro labiale) au format de Plotagon Studio.
2
+
3
+ Format CONFIRME par decodage d'un export officiel : messages protobuf
4
+ concatenes, un par trame de 2048 echantillons (~46.4 ms a 44100 Hz) :
5
+
6
+ champ 1 (tag 0x0d, float32 LE) : debut en secondes (omis si 0)
7
+ champ 2 (tag 0x15, float32 LE) : fin en secondes
8
+ champ 3 (tag 0x1a, string) : phoneme
9
+
10
+ Phonemes observes : sil, ae, d, eh, l, m, n, ow, uu, uw.
11
+ """
12
+
13
+ import io
14
+ import struct
15
+ import wave
16
+
17
+ FRAME_SAMPLES = 2048
18
+
19
+ # Cycle de voyelles pour la synchro labiale approximative (amplitude-based).
20
+ _MOUTH_CYCLE = ["eh", "ae", "ow", "eh", "uw", "ae"]
21
+
22
+
23
+ def phonemes_from_wav(wav_bytes):
24
+ """Synchro labiale approximative par enveloppe d'amplitude.
25
+
26
+ L'appli genere les phonemes par analyse du signal ; ici on approxime :
27
+ trames silencieuses -> "sil", trames parlees -> cycle de voyelles.
28
+ """
29
+ import numpy as np
30
+
31
+ with wave.open(io.BytesIO(wav_bytes)) as w:
32
+ rate = w.getframerate()
33
+ pcm = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2")
34
+
35
+ records = []
36
+ n_frames = max(1, (len(pcm) + FRAME_SAMPLES - 1) // FRAME_SAMPLES)
37
+ rms = np.array([
38
+ np.sqrt(np.mean(
39
+ pcm[i * FRAME_SAMPLES:(i + 1) * FRAME_SAMPLES].astype("f8") ** 2
40
+ )) if len(pcm[i * FRAME_SAMPLES:(i + 1) * FRAME_SAMPLES]) else 0.0
41
+ for i in range(n_frames)
42
+ ])
43
+ threshold = max(300.0, float(rms.max()) * 0.08)
44
+
45
+ mouth = 0
46
+ for i in range(n_frames):
47
+ start = i * FRAME_SAMPLES / rate
48
+ end = min((i + 1) * FRAME_SAMPLES, len(pcm)) / rate
49
+ if rms[i] >= threshold:
50
+ label = _MOUTH_CYCLE[(mouth // 2) % len(_MOUTH_CYCLE)]
51
+ mouth += 1
52
+ else:
53
+ label = "sil"
54
+ mouth = 0
55
+ records.append((start, end, label))
56
+ return records
57
+
58
+
59
+ def encode_phonemes(records):
60
+ """Encode les enregistrements au format protobuf observe."""
61
+ out = bytearray()
62
+ for start, end, label in records:
63
+ body = bytearray()
64
+ if start > 0.0:
65
+ body += b"\x0d" + struct.pack("<f", start)
66
+ body += b"\x15" + struct.pack("<f", end)
67
+ raw = label.encode()
68
+ body += b"\x1a" + bytes([len(raw)]) + raw
69
+ out += b"\x0a" + bytes([len(body)]) + body
70
+ return bytes(out)
@@ -0,0 +1,163 @@
1
+ """Synthese vocale Edge TTS au format audio de Plotagon Studio.
2
+
3
+ Format CONFIRME (export officiel 1.11.0) : WAV PCM mono 44100 Hz 16 bits.
4
+
5
+ Deux optimisations pour l'iteration rapide :
6
+ - cache disque (%LOCALAPPDATA%/plotagon-director/tts-cache) par hash de
7
+ (texte, voix, rate, pitch) — un rebuild sans changement ne touche pas
8
+ au reseau ;
9
+ - synthese parallele (asyncio) pour les films a nombreuses repliques.
10
+ """
11
+
12
+ import asyncio
13
+ import hashlib
14
+ import io
15
+ import os
16
+ import wave
17
+ from pathlib import Path
18
+
19
+ TARGET_RATE = 44100
20
+ CONCURRENCY = 4
21
+
22
+ CACHE_DIR = (
23
+ Path(os.environ.get("LOCALAPPDATA", str(Path.home())))
24
+ / "plotagon-director" / "tts-cache"
25
+ )
26
+
27
+
28
+ def _inject_truststore():
29
+ """Fait utiliser a Python le magasin de certificats Windows.
30
+
31
+ Indispensable derriere un antivirus qui intercepte le TLS (ex. Norton).
32
+ """
33
+ try:
34
+ import truststore
35
+ truststore.inject_into_ssl()
36
+ except ImportError:
37
+ pass
38
+
39
+
40
+ def _cache_path(text, voice, rate, pitch):
41
+ key = "%s|%s|%s|%s" % (text, voice, rate or "", pitch or "")
42
+ return CACHE_DIR / (hashlib.sha256(key.encode("utf-8")).hexdigest() + ".wav")
43
+
44
+
45
+ async def _fetch_mp3(text, voice, rate=None, pitch=None):
46
+ import edge_tts
47
+
48
+ kwargs = {}
49
+ if rate:
50
+ kwargs["rate"] = rate
51
+ if pitch:
52
+ kwargs["pitch"] = pitch
53
+ communicate = edge_tts.Communicate(text, voice, **kwargs)
54
+ buf = io.BytesIO()
55
+ async for chunk in communicate.stream():
56
+ if chunk["type"] == "audio":
57
+ buf.write(chunk["data"])
58
+ return buf.getvalue()
59
+
60
+
61
+ def _mp3_to_wav(mp3_bytes):
62
+ import numpy as np
63
+ import soundfile as sf
64
+
65
+ data, sr = sf.read(io.BytesIO(mp3_bytes), dtype="float32")
66
+ if data.ndim > 1:
67
+ data = data.mean(axis=1)
68
+ if sr != TARGET_RATE:
69
+ n_out = int(round(len(data) * TARGET_RATE / sr))
70
+ x_old = np.linspace(0.0, 1.0, num=len(data), endpoint=False)
71
+ x_new = np.linspace(0.0, 1.0, num=n_out, endpoint=False)
72
+ data = np.interp(x_new, x_old, data).astype("float32")
73
+
74
+ pcm = (np.clip(data, -1.0, 1.0) * 32767).astype("<i2")
75
+ out = io.BytesIO()
76
+ with wave.open(out, "wb") as w:
77
+ w.setnchannels(1)
78
+ w.setsampwidth(2)
79
+ w.setframerate(TARGET_RATE)
80
+ w.writeframes(pcm.tobytes())
81
+ return out.getvalue()
82
+
83
+
84
+ def synthesize_wav(text, voice, rate=None, pitch=None, use_cache=True):
85
+ """Synthetise une replique et retourne des octets WAV 44.1k mono 16b."""
86
+ if use_cache:
87
+ cached = _cache_path(text, voice, rate, pitch)
88
+ if cached.exists():
89
+ return cached.read_bytes()
90
+
91
+ _inject_truststore()
92
+ wav = _mp3_to_wav(asyncio.run(_fetch_mp3(text, voice, rate, pitch)))
93
+
94
+ if use_cache:
95
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
96
+ cached.write_bytes(wav)
97
+ return wav
98
+
99
+
100
+ def synthesize_many(jobs, use_cache=True, progress=None):
101
+ """Synthetise plusieurs repliques en parallele.
102
+
103
+ jobs : liste de dicts {"guid", "text", "voice", "rate", "pitch"}.
104
+ progress : callback(job, from_cache) appele apres chaque replique.
105
+ Retourne {guid: wav_bytes}.
106
+ """
107
+ results = {}
108
+ to_fetch = []
109
+ for job in jobs:
110
+ cached = _cache_path(job["text"], job["voice"], job.get("rate"), job.get("pitch"))
111
+ if use_cache and cached.exists():
112
+ results[job["guid"]] = cached.read_bytes()
113
+ if progress:
114
+ progress(job, True)
115
+ else:
116
+ to_fetch.append(job)
117
+
118
+ if to_fetch:
119
+ _inject_truststore()
120
+
121
+ async def _run_all():
122
+ sem = asyncio.Semaphore(CONCURRENCY)
123
+
124
+ async def _one(job):
125
+ async with sem:
126
+ mp3 = await _fetch_mp3(
127
+ job["text"], job["voice"], job.get("rate"), job.get("pitch")
128
+ )
129
+ return job, mp3
130
+
131
+ return await asyncio.gather(*(_one(j) for j in to_fetch))
132
+
133
+ for job, mp3 in asyncio.run(_run_all()):
134
+ wav = _mp3_to_wav(mp3)
135
+ results[job["guid"]] = wav
136
+ if use_cache:
137
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
138
+ _cache_path(
139
+ job["text"], job["voice"], job.get("rate"), job.get("pitch")
140
+ ).write_bytes(wav)
141
+ if progress:
142
+ progress(job, False)
143
+
144
+ return results
145
+
146
+
147
+ def list_voices(locale_prefix=None):
148
+ """Liste les voix Edge TTS, filtrees par prefixe de locale (ex. 'fr')."""
149
+ _inject_truststore()
150
+ import edge_tts
151
+
152
+ voices = asyncio.run(edge_tts.list_voices())
153
+ if locale_prefix:
154
+ voices = [
155
+ v for v in voices
156
+ if v["Locale"].lower().startswith(locale_prefix.lower())
157
+ ]
158
+ return sorted(voices, key=lambda v: v["ShortName"])
159
+
160
+
161
+ def wav_duration_ms(wav_bytes):
162
+ with wave.open(io.BytesIO(wav_bytes)) as w:
163
+ return int(round(1000.0 * w.getnframes() / w.getframerate()))
@@ -0,0 +1,308 @@
1
+ """CLI plotagon-director.
2
+
3
+ Commandes : validate | build | batch | parse | decompile | diff | list |
4
+ positions | voices | say | new | doctor
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ from pathlib import Path
10
+ import sys
11
+
12
+ import yaml
13
+
14
+ from .core.archive import read_plot, write_plot
15
+ from .core.plotdoc import MS_PER_CHAR, build_plotdoc
16
+ from .core.validator import load_project, validate_project
17
+ from .library import load_library
18
+
19
+ NEW_TEMPLATE = """\
20
+ # Scenario Plotagon — IDs valides: `plotagon-director list scenes|characters|expressions|actions|music`
21
+ project:
22
+ title: "%s"
23
+ language: "fr-FR"
24
+
25
+ # cast_file: cast-commun.yaml # optionnel: cast partage entre films
26
+
27
+ cast:
28
+ - ref: paul
29
+ character: news.paul
30
+ name: "Paul"
31
+ voice: fr-FR-HenriNeural # optionnel: voix Edge TTS (`plotagon-director voices fr`)
32
+ # rate: "+10%%" # optionnel
33
+ # pitch: "-5Hz" # optionnel
34
+
35
+ scenes:
36
+ - scene: spaces.greenscreen
37
+ # music: music.corny # optionnel (music.stopmusic pour arreter)
38
+ actors:
39
+ - ref: paul
40
+ position: Left # slots: `plotagon-director positions <scene>`
41
+ dialogue:
42
+ - actor: paul
43
+ expression: happy # 88 expressions: `plotagon-director list expressions`
44
+ text: "Bonjour !"
45
+ # camera: wide shot # optionnel: 10 types (docs/PLOT_FORMAT.md)
46
+ # - actor: paul # interaction a 2 personnages
47
+ # action: handshake
48
+ # target: autre_ref
49
+ """
50
+
51
+
52
+ def print_report(errors, warnings, strict=False):
53
+ print("PLOTAGON VALIDATOR")
54
+ print("=" * 20)
55
+ for w in warnings:
56
+ print(f"WARNING {w}")
57
+ for e in errors:
58
+ print(f"ERROR {e}")
59
+ if errors or (strict and warnings):
60
+ print("RESULT FAIL")
61
+ sys.exit(1)
62
+ print("RESULT PASS" if not warnings else "RESULT PASS WITH WARNINGS")
63
+
64
+
65
+ def _format_srt_time(ms):
66
+ h, rem = divmod(ms, 3600000)
67
+ m, rem = divmod(rem, 60000)
68
+ s, ms = divmod(rem, 1000)
69
+ return "%02d:%02d:%02d,%03d" % (h, m, s, ms)
70
+
71
+
72
+ def _write_srt(path, entries):
73
+ """entries: liste de (debut_ms, fin_ms, texte)."""
74
+ blocks = []
75
+ for i, (start, end, text) in enumerate(entries, start=1):
76
+ blocks.append("%d\n%s --> %s\n%s\n" % (
77
+ i, _format_srt_time(start), _format_srt_time(end), text
78
+ ))
79
+ Path(path).write_text("\n".join(blocks), encoding="utf-8")
80
+
81
+
82
+ SRT_GAP_MS = 400 # estimation des transitions entre repliques dans l'appli
83
+
84
+
85
+ def do_build(project_path, output, no_voices=False, strict=False, srt=None,
86
+ no_cache=False):
87
+ errors, warnings = validate_project(project_path)
88
+ if errors or (strict and warnings):
89
+ print_report(errors, warnings, strict)
90
+ plotdoc, voice_jobs = build_plotdoc(load_project(project_path), load_library())
91
+
92
+ media = {}
93
+ durations = {}
94
+ if voice_jobs and not no_voices:
95
+ from .audio.phonemes import encode_phonemes, phonemes_from_wav
96
+ from .audio.tts import synthesize_many, wav_duration_ms
97
+
98
+ def progress(job, from_cache):
99
+ print("TTS %s [%s] %s" % (
100
+ "(cache)" if from_cache else " ",
101
+ job["voice"], job["text"][:50],
102
+ ))
103
+
104
+ wavs = synthesize_many(voice_jobs, use_cache=not no_cache,
105
+ progress=progress)
106
+ for guid, wav in wavs.items():
107
+ media[guid] = {
108
+ "wav": wav,
109
+ "phonemes": encode_phonemes(phonemes_from_wav(wav)),
110
+ }
111
+ durations[guid] = wav_duration_ms(wav)
112
+ plotdoc["lengthSeconds"] = str(max(1000, sum(durations.values())))
113
+ elif voice_jobs:
114
+ plotdoc["voicerecordings"] = []
115
+ for instr in plotdoc["contents"]["instructions"]:
116
+ instr["parameters"]["isRecorded"] = False
117
+ instr["parameters"]["playRecording"] = False
118
+
119
+ write_plot(plotdoc, output, media)
120
+ print("OK %s (%d instructions, %d voix)" % (
121
+ output, len(plotdoc["contents"]["instructions"]), len(media)
122
+ ))
123
+
124
+ if srt:
125
+ entries = []
126
+ t = 0
127
+ for instr in plotdoc["contents"]["instructions"]:
128
+ if instr["type"] != "dialogue":
129
+ continue
130
+ params = instr["parameters"]
131
+ text = params.get("text", {}).get("text", "")
132
+ duration = durations.get(
133
+ params["GUID"], max(1500, len(text) * MS_PER_CHAR)
134
+ )
135
+ entries.append((t, t + duration, text))
136
+ t += duration + SRT_GAP_MS
137
+ _write_srt(srt, entries)
138
+ print("OK %s (%d sous-titres)" % (srt, len(entries)))
139
+
140
+
141
+ def do_list(category, filter_term=None):
142
+ library = load_library()
143
+ if library is None:
144
+ sys.exit("library.json absent — lancer: python -m plotagon_director.library.builder")
145
+ if category not in library or category == "source":
146
+ sys.exit("Categories: %s" % ", ".join(k for k in library if k != "source"))
147
+ items = library[category]
148
+ if category == "actions":
149
+ rows = [(k, v.get("phrase", "")) for k, v in items.items()]
150
+ if filter_term:
151
+ rows = [r for r in rows if filter_term.lower() in r[0].lower()]
152
+ for k, phrase in rows:
153
+ print("%-22s %s" % (k, phrase))
154
+ print("-- %d actions" % len(rows))
155
+ return
156
+ if filter_term:
157
+ items = [i for i in items if filter_term.lower() in i.lower()]
158
+ for item in items:
159
+ print(item)
160
+ print("-- %d %s" % (len(items), category))
161
+
162
+
163
+ def main():
164
+ parser = argparse.ArgumentParser(prog="plotagon-director")
165
+ sub = parser.add_subparsers(dest="command", required=True)
166
+
167
+ pval = sub.add_parser("validate", help="valide un project.yaml")
168
+ pval.add_argument("project")
169
+ pval.add_argument("--strict", action="store_true",
170
+ help="les warnings deviennent des erreurs")
171
+
172
+ pbuild = sub.add_parser("build", help="genere un .plot depuis un project.yaml")
173
+ pbuild.add_argument("project")
174
+ pbuild.add_argument("output")
175
+ pbuild.add_argument("--no-voices", action="store_true",
176
+ help="ignore les champs voice (pas de reseau)")
177
+ pbuild.add_argument("--no-cache", action="store_true",
178
+ help="resynthetise tout (ignore le cache TTS)")
179
+ pbuild.add_argument("--strict", action="store_true")
180
+ pbuild.add_argument("--srt", metavar="FICHIER",
181
+ help="ecrit aussi les sous-titres SRT (temps estimes)")
182
+
183
+ pbatch = sub.add_parser("batch", help="build tous les .yaml d'un dossier")
184
+ pbatch.add_argument("directory")
185
+ pbatch.add_argument("outdir", nargs="?", default=None)
186
+ pbatch.add_argument("--no-voices", action="store_true")
187
+
188
+ pparse = sub.add_parser("parse", help="lit un .plot et affiche son JSON")
189
+ pparse.add_argument("plot")
190
+
191
+ pdec = sub.add_parser("decompile", help="convertit un .plot en project.yaml")
192
+ pdec.add_argument("plot")
193
+ pdec.add_argument("output", nargs="?", default=None,
194
+ help="fichier yaml de sortie (defaut: stdout)")
195
+
196
+ pdiff = sub.add_parser("diff", help="compare la structure de deux .plot")
197
+ pdiff.add_argument("plot_a")
198
+ pdiff.add_argument("plot_b")
199
+
200
+ plist = sub.add_parser("list", help="liste la bibliotheque (scenes, characters...)")
201
+ plist.add_argument("category")
202
+ plist.add_argument("--filter", dest="filter_term", default=None)
203
+
204
+ ppos = sub.add_parser("positions", help="liste les positions d'acteurs d'une scene")
205
+ ppos.add_argument("scene")
206
+
207
+ pvoi = sub.add_parser("voices", help="liste les voix Edge TTS")
208
+ pvoi.add_argument("locale", nargs="?", default=None,
209
+ help="prefixe de locale, ex. fr ou fr-CA")
210
+
211
+ psay = sub.add_parser("say", help="synthetise une phrase pour ecouter une voix")
212
+ psay.add_argument("text")
213
+ psay.add_argument("voice")
214
+ psay.add_argument("output", nargs="?", default="voix-test.wav")
215
+
216
+ pnew = sub.add_parser("new", help="cree un squelette de project.yaml")
217
+ pnew.add_argument("title")
218
+ pnew.add_argument("output", nargs="?", default=None)
219
+
220
+ pdoc = sub.add_parser("doctor", help="diagnostic d'environnement")
221
+ pdoc.add_argument("--offline", action="store_true",
222
+ help="saute le test reseau/TLS")
223
+
224
+ args = parser.parse_args()
225
+
226
+ if args.command == "validate":
227
+ errors, warnings = validate_project(args.project)
228
+ print_report(errors, warnings, args.strict)
229
+
230
+ elif args.command == "build":
231
+ do_build(args.project, args.output, args.no_voices, args.strict,
232
+ args.srt, args.no_cache)
233
+
234
+ elif args.command == "batch":
235
+ directory = Path(args.directory)
236
+ outdir = Path(args.outdir) if args.outdir else directory
237
+ outdir.mkdir(parents=True, exist_ok=True)
238
+ files = sorted(directory.glob("*.yaml")) + sorted(directory.glob("*.yml"))
239
+ if not files:
240
+ sys.exit("Aucun .yaml dans %s" % directory)
241
+ for f in files:
242
+ print("== %s" % f.name)
243
+ do_build(str(f), str(outdir / (f.stem + ".plot")), args.no_voices)
244
+
245
+ elif args.command == "parse":
246
+ print(json.dumps(read_plot(args.plot), ensure_ascii=False, indent=2))
247
+
248
+ elif args.command == "decompile":
249
+ from .core.decompiler import plotdoc_to_project
250
+ project, unknown = plotdoc_to_project(read_plot(args.plot))
251
+ text = yaml.safe_dump(project, allow_unicode=True, sort_keys=False,
252
+ default_flow_style=False)
253
+ if args.output:
254
+ Path(args.output).write_text(text, encoding="utf-8")
255
+ print("OK %s" % args.output)
256
+ else:
257
+ print(text)
258
+ if unknown:
259
+ print("ATTENTION: %d instruction(s) de type inconnu conservees "
260
+ "dans `unknown_instructions` (a analyser)." % unknown,
261
+ file=sys.stderr)
262
+
263
+ elif args.command == "diff":
264
+ from .core.diffplot import diff_plots
265
+ print(diff_plots(args.plot_a, args.plot_b))
266
+
267
+ elif args.command == "list":
268
+ do_list(args.category, args.filter_term)
269
+
270
+ elif args.command == "positions":
271
+ library = load_library() or {}
272
+ slots = library.get("scene_positions", {}).get(args.scene)
273
+ if slots is None:
274
+ sys.exit("Scene inconnue ou positions non extraites: %s "
275
+ "(relancer library.builder ?)" % args.scene)
276
+ for s in slots:
277
+ print("%-24s %s" % (s["id"], s["text"]))
278
+ print("-- %d positions" % len(slots))
279
+
280
+ elif args.command == "voices":
281
+ from .audio.tts import list_voices
282
+ voices = list_voices(args.locale)
283
+ for v in voices:
284
+ print("%-28s %-8s %s" % (v["ShortName"], v["Gender"], v["Locale"]))
285
+ print("-- %d voix" % len(voices))
286
+
287
+ elif args.command == "say":
288
+ from .audio.tts import synthesize_wav
289
+ wav = synthesize_wav(args.text, args.voice)
290
+ Path(args.output).write_bytes(wav)
291
+ print("OK %s" % args.output)
292
+
293
+ elif args.command == "new":
294
+ out = args.output or (args.title.lower().replace(" ", "-") + ".yaml")
295
+ if Path(out).exists():
296
+ sys.exit("%s existe deja." % out)
297
+ Path(out).write_text(NEW_TEMPLATE % args.title, encoding="utf-8")
298
+ print("OK %s" % out)
299
+
300
+ elif args.command == "doctor":
301
+ from .core.doctor import run_doctor
302
+ report, ok = run_doctor(check_network=not args.offline)
303
+ print(report)
304
+ sys.exit(0 if ok else 1)
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()
File without changes
@@ -0,0 +1,35 @@
1
+ """Lecture/ecriture du conteneur .plot.
2
+
3
+ .plot = archive ZIP contenant "<guid-projet>.plotdoc" (JSON UTF-8) et,
4
+ pour chaque replique avec voix, "<guid-projet>/<guid-instruction>.wav"
5
+ et ".phonemes".
6
+ """
7
+
8
+ import json
9
+ import zipfile
10
+
11
+
12
+ def write_plot(plotdoc, out_path, media=None):
13
+ """Ecrit un .plot.
14
+
15
+ media : dict {guid_instruction: {"wav": bytes, "phonemes": bytes}}.
16
+ """
17
+ inner_name = "%s.plotdoc" % plotdoc["id"]
18
+ payload = json.dumps(plotdoc, ensure_ascii=False, indent=2)
19
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
20
+ zf.writestr(inner_name, payload)
21
+ for guid, files in (media or {}).items():
22
+ base = "%s/%s" % (plotdoc["id"], guid)
23
+ zf.writestr(base + ".wav", files["wav"])
24
+ if files.get("phonemes"):
25
+ zf.writestr(base + ".phonemes", files["phonemes"])
26
+
27
+
28
+ def read_plot(path):
29
+ """Lit un .plot et retourne le dict .plotdoc."""
30
+ with zipfile.ZipFile(path) as zf:
31
+ names = [n for n in zf.namelist() if n.endswith(".plotdoc")]
32
+ if not names:
33
+ raise ValueError("Aucun .plotdoc dans l'archive: %s" % path)
34
+ with zf.open(names[0]) as f:
35
+ return json.load(f)