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
|
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()))
|
plotagon_director/cli.py
ADDED
|
@@ -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)
|