humanizer-th 0.0.0-stage → 0.5.0
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/LICENSE +21 -0
- package/README.en.md +176 -0
- package/README.md +227 -2
- package/bin/cli.js +123 -0
- package/package.json +33 -4
- package/skills/humanizer-th/SKILL.md +254 -0
- package/skills/humanizer-th/agents/openai.yaml +4 -0
- package/skills/humanizer-th/references/patterns.md +574 -0
- package/skills/humanizer-th/references/registers.md +127 -0
- package/skills/humanizer-th/references/style.md +102 -0
- package/skills/humanizer-th/references/word-swaps.md +206 -0
- package/skills/localize-th/SKILL.md +213 -0
- package/skills/localize-th/agents/openai.yaml +4 -0
- package/skills/localize-th/references/characters.md +89 -0
- package/skills/localize-th/references/formats.md +154 -0
- package/skills/localize-th/references/games.md +74 -0
- package/skills/localize-th/references/glossary.md +168 -0
- package/skills/localize-th/references/patterns.md +574 -0
- package/skills/localize-th/references/registers.md +127 -0
- package/skills/localize-th/references/style.md +102 -0
- package/skills/localize-th/references/subtitles.md +110 -0
- package/skills/localize-th/references/translation.md +255 -0
- package/skills/localize-th/references/ui.md +117 -0
- package/skills/localize-th/references/word-swaps.md +206 -0
- package/skills/localize-th/scripts/check_translation.py +484 -0
- package/skills/script-th/SKILL.md +162 -0
- package/skills/script-th/agents/openai.yaml +4 -0
- package/skills/script-th/references/characters.md +89 -0
- package/skills/script-th/references/dialogue.md +236 -0
- package/skills/script-th/references/patterns.md +574 -0
- package/skills/script-th/references/registers.md +127 -0
- package/skills/script-th/references/style.md +102 -0
- package/skills/script-th/references/video-prompts.md +103 -0
- package/skills/script-th/references/voice.md +107 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check a Thai translation against its English source.
|
|
3
|
+
|
|
4
|
+
Errors are things that break the file: changed timings, missing keys,
|
|
5
|
+
missing or changed placeholders and tags, invalid JSON or XML.
|
|
6
|
+
Warnings are style limits: subtitle line length, line count, reading speed,
|
|
7
|
+
"!" in the text, and untranslated entries.
|
|
8
|
+
|
|
9
|
+
python3 check_translation.py SOURCE TRANSLATION
|
|
10
|
+
python3 check_translation.py FILE (PO, XLIFF, CSV hold both)
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--max-line N max characters per subtitle line (default 35)
|
|
14
|
+
--max-lines N max lines per subtitle block (default 2)
|
|
15
|
+
--max-cps N max characters per second (default 20)
|
|
16
|
+
--source-col C CSV source column (default: English, en, source)
|
|
17
|
+
--target-col C CSV target column (default: Thai, th, target)
|
|
18
|
+
--strict exit 1 on warnings too
|
|
19
|
+
|
|
20
|
+
Thai characters are counted without tone marks and upper or lower vowels,
|
|
21
|
+
which take no horizontal space. Exit code is 1 when there is an error.
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import csv
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
import unicodedata
|
|
29
|
+
import xml.etree.ElementTree as ET
|
|
30
|
+
from collections import Counter
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
PLACEHOLDER_RE = re.compile(
|
|
34
|
+
r"\{\{[^{}]+\}\}" # {{count}} i18next
|
|
35
|
+
r"|\{[A-Za-z0-9_.]+\}" # {name} {0}
|
|
36
|
+
r"|\{[A-Za-z0-9_]+," # {count, plural, ... ICU argument
|
|
37
|
+
r"|%(?:\d+\$)?[-+0#]*\d*(?:\.\d+)?[sdifuxXc@]" # %s %1$d %.2f %@
|
|
38
|
+
r"|%\([A-Za-z_]+\)[sd]" # %(name)s
|
|
39
|
+
r"|\[[A-Za-z][A-Za-z0-9_]*\]" # [PlayerName]
|
|
40
|
+
r"|\$[A-Za-z_][A-Za-z0-9_]*\$" # $gold$
|
|
41
|
+
r"|\\n" # literal \n
|
|
42
|
+
)
|
|
43
|
+
HTML_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
|
44
|
+
ASS_TAG_RE = re.compile(r"\{\\[^{}]*\}|\\[Nnh]")
|
|
45
|
+
TIME_SRT = re.compile(r"(\d{2}):(\d{2}):(\d{2})[,.](\d{3})")
|
|
46
|
+
TIME_VTT = re.compile(r"(?:(\d{2,}):)?(\d{2}):(\d{2})\.(\d{3})")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Report:
|
|
50
|
+
def __init__(self):
|
|
51
|
+
self.errors = []
|
|
52
|
+
self.warnings = []
|
|
53
|
+
|
|
54
|
+
def error(self, where, msg):
|
|
55
|
+
self.errors.append(f"{where}: {msg}")
|
|
56
|
+
|
|
57
|
+
def warn(self, where, msg):
|
|
58
|
+
self.warnings.append(f"{where}: {msg}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def visible_len(text):
|
|
62
|
+
"""Characters that take horizontal space; Thai combining marks are not counted."""
|
|
63
|
+
return sum(1 for ch in text if unicodedata.category(ch) != "Mn")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def tokens(text, subtitle=False, ass=False):
|
|
67
|
+
# Subtitles have no placeholders; [laughs] and the like are translated.
|
|
68
|
+
found = HTML_TAG_RE.findall(text)
|
|
69
|
+
if not subtitle:
|
|
70
|
+
found += PLACEHOLDER_RE.findall(text)
|
|
71
|
+
if ass:
|
|
72
|
+
found += ASS_TAG_RE.findall(text)
|
|
73
|
+
return Counter(found)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def compare_tokens(report, where, src, dst, subtitle=False, ass=False):
|
|
77
|
+
a, b = tokens(src, subtitle, ass), tokens(dst, subtitle, ass)
|
|
78
|
+
if a == b:
|
|
79
|
+
return
|
|
80
|
+
missing = a - b
|
|
81
|
+
extra = b - a
|
|
82
|
+
if missing:
|
|
83
|
+
report.error(where, "missing " + ", ".join(sorted(missing.elements())))
|
|
84
|
+
if extra:
|
|
85
|
+
report.error(where, "unexpected " + ", ".join(sorted(extra.elements())))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_style(report, where, text):
|
|
89
|
+
if "!" in strip_markup(text):
|
|
90
|
+
report.warn(where, 'contains "!"')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def strip_markup(text):
|
|
94
|
+
return ASS_TAG_RE.sub(" ", HTML_TAG_RE.sub("", text))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------- subtitles ----------
|
|
98
|
+
|
|
99
|
+
def srt_seconds(stamp):
|
|
100
|
+
h, m, s, ms = (int(x) for x in TIME_SRT.match(stamp).groups())
|
|
101
|
+
return h * 3600 + m * 60 + s + ms / 1000
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_srt(text):
|
|
105
|
+
cues = []
|
|
106
|
+
for block in re.split(r"\n\s*\n", text.replace("\r\n", "\n").strip()):
|
|
107
|
+
lines = block.split("\n")
|
|
108
|
+
if len(lines) < 2 or "-->" not in lines[1]:
|
|
109
|
+
if lines and lines[0].strip():
|
|
110
|
+
cues.append({"id": lines[0].strip(), "time": None, "lines": lines[1:]})
|
|
111
|
+
continue
|
|
112
|
+
start, _, end = lines[1].partition("-->")
|
|
113
|
+
cues.append({
|
|
114
|
+
"id": lines[0].strip(),
|
|
115
|
+
"time": lines[1].strip(),
|
|
116
|
+
"start": srt_seconds(start.strip()),
|
|
117
|
+
"end": srt_seconds(end.strip().split()[0]),
|
|
118
|
+
"lines": lines[2:],
|
|
119
|
+
})
|
|
120
|
+
return cues
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def vtt_seconds(stamp):
|
|
124
|
+
h, m, s, ms = TIME_VTT.match(stamp).groups()
|
|
125
|
+
return int(h or 0) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_vtt(text):
|
|
129
|
+
cues = []
|
|
130
|
+
blocks = re.split(r"\n\s*\n", text.replace("\r\n", "\n").strip())
|
|
131
|
+
for block in blocks[1:]: # first block is the WEBVTT header
|
|
132
|
+
lines = block.split("\n")
|
|
133
|
+
if lines[0].startswith(("NOTE", "STYLE", "REGION")):
|
|
134
|
+
continue
|
|
135
|
+
idx = 0 if "-->" in lines[0] else 1
|
|
136
|
+
if idx >= len(lines) or "-->" not in lines[idx]:
|
|
137
|
+
continue
|
|
138
|
+
start, _, rest = lines[idx].partition("-->")
|
|
139
|
+
cues.append({
|
|
140
|
+
"id": lines[0].strip() if idx else "",
|
|
141
|
+
"time": lines[idx].strip(),
|
|
142
|
+
"start": vtt_seconds(start.strip()),
|
|
143
|
+
"end": vtt_seconds(rest.strip().split()[0]),
|
|
144
|
+
"lines": lines[idx + 1:],
|
|
145
|
+
})
|
|
146
|
+
return cues
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def ass_seconds(stamp):
|
|
150
|
+
h, m, s = stamp.split(":")
|
|
151
|
+
return int(h) * 3600 + int(m) * 60 + float(s)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_ass(text):
|
|
155
|
+
cues = []
|
|
156
|
+
for n, line in enumerate(text.replace("\r\n", "\n").split("\n"), 1):
|
|
157
|
+
if not line.startswith("Dialogue:"):
|
|
158
|
+
continue
|
|
159
|
+
fields = line[len("Dialogue:"):].split(",", 9)
|
|
160
|
+
if len(fields) < 10:
|
|
161
|
+
continue
|
|
162
|
+
cues.append({
|
|
163
|
+
"id": f"line {n}",
|
|
164
|
+
"time": ",".join(f.strip() for f in fields[:9]),
|
|
165
|
+
"start": ass_seconds(fields[1].strip()),
|
|
166
|
+
"end": ass_seconds(fields[2].strip()),
|
|
167
|
+
"lines": fields[9].split("\\N"),
|
|
168
|
+
})
|
|
169
|
+
return cues
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def check_subtitles(report, src_cues, dst_cues, opts, ass=False):
|
|
173
|
+
if len(src_cues) != len(dst_cues):
|
|
174
|
+
report.error("file", f"{len(src_cues)} blocks in source, {len(dst_cues)} in translation")
|
|
175
|
+
for i, (s, d) in enumerate(zip(src_cues, dst_cues), 1):
|
|
176
|
+
where = f"block {d['id'] or i}"
|
|
177
|
+
if s["time"] != d["time"]:
|
|
178
|
+
report.error(where, f"timing changed: {s['time']!r} -> {d['time']!r}")
|
|
179
|
+
if s["id"] != d["id"] and not ass:
|
|
180
|
+
report.error(where, f"block id changed: {s['id']!r} -> {d['id']!r}")
|
|
181
|
+
src_text = "\n".join(s["lines"])
|
|
182
|
+
dst_text = "\n".join(d["lines"])
|
|
183
|
+
compare_tokens(report, where, src_text, dst_text, subtitle=True, ass=ass)
|
|
184
|
+
lines = [strip_markup(l).strip() for l in d["lines"] if strip_markup(l).strip()]
|
|
185
|
+
if not lines:
|
|
186
|
+
if strip_markup(src_text).strip():
|
|
187
|
+
report.warn(where, "empty")
|
|
188
|
+
continue
|
|
189
|
+
if len(lines) > opts.max_lines:
|
|
190
|
+
report.warn(where, f"{len(lines)} lines (max {opts.max_lines})")
|
|
191
|
+
for line in lines:
|
|
192
|
+
n = visible_len(line)
|
|
193
|
+
if n > opts.max_line:
|
|
194
|
+
report.warn(where, f"line is {n} characters (max {opts.max_line}): {line}")
|
|
195
|
+
duration = d.get("end", 0) - d.get("start", 0) if d.get("time") else 0
|
|
196
|
+
if duration > 0:
|
|
197
|
+
cps = sum(visible_len(l) for l in lines) / duration
|
|
198
|
+
if cps > opts.max_cps:
|
|
199
|
+
report.warn(where, f"{cps:.1f} characters per second (max {opts.max_cps})")
|
|
200
|
+
if any(l.endswith(".") and not l.endswith("...") for l in lines):
|
|
201
|
+
report.warn(where, "line ends with a period")
|
|
202
|
+
check_style(report, where, dst_text)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------- key-value formats ----------
|
|
206
|
+
|
|
207
|
+
def flatten_json(data, prefix=""):
|
|
208
|
+
out = {}
|
|
209
|
+
if isinstance(data, dict):
|
|
210
|
+
for k, v in data.items():
|
|
211
|
+
out.update(flatten_json(v, f"{prefix}{k}."))
|
|
212
|
+
elif isinstance(data, list):
|
|
213
|
+
for i, v in enumerate(data):
|
|
214
|
+
out.update(flatten_json(v, f"{prefix}{i}."))
|
|
215
|
+
else:
|
|
216
|
+
out[prefix[:-1]] = data
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def load_json(report, path, label):
|
|
221
|
+
try:
|
|
222
|
+
return json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
223
|
+
except json.JSONDecodeError as e:
|
|
224
|
+
report.error(label, f"invalid JSON: {e}")
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def strings_pairs(text):
|
|
229
|
+
pair = re.compile(r'"((?:[^"\\]|\\.)*)"\s*=\s*"((?:[^"\\]|\\.)*)"\s*;')
|
|
230
|
+
return dict(pair.findall(text))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def android_strings(report, path, label):
|
|
234
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
235
|
+
try:
|
|
236
|
+
root = ET.fromstring(raw)
|
|
237
|
+
except ET.ParseError as e:
|
|
238
|
+
report.error(label, f"invalid XML: {e}")
|
|
239
|
+
return None
|
|
240
|
+
out = {}
|
|
241
|
+
for el in root:
|
|
242
|
+
name = el.get("name")
|
|
243
|
+
if not name or el.get("translatable") == "false":
|
|
244
|
+
continue
|
|
245
|
+
if el.tag == "string":
|
|
246
|
+
out[name] = inner_xml(el)
|
|
247
|
+
elif el.tag in ("plurals", "string-array"):
|
|
248
|
+
for i, item in enumerate(el.findall("item")):
|
|
249
|
+
key = item.get("quantity") or str(i)
|
|
250
|
+
out[f"{name}[{key}]"] = inner_xml(item)
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def inner_xml(el):
|
|
255
|
+
parts = [el.text or ""]
|
|
256
|
+
for child in el:
|
|
257
|
+
parts.append(ET.tostring(child, encoding="unicode"))
|
|
258
|
+
return "".join(parts)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def compare_maps(report, src, dst, skip=lambda k: False):
|
|
262
|
+
for key in src:
|
|
263
|
+
if skip(key):
|
|
264
|
+
continue
|
|
265
|
+
if key not in dst:
|
|
266
|
+
plural = re.match(r"(.+)\[(zero|one|two|few|many)\]$", key)
|
|
267
|
+
if plural and f"{plural.group(1)}[other]" in dst:
|
|
268
|
+
continue # Thai only needs the "other" plural form
|
|
269
|
+
report.error(f"key {key}", "missing in translation")
|
|
270
|
+
continue
|
|
271
|
+
s, d = src[key], dst[key]
|
|
272
|
+
if not isinstance(s, str) or not isinstance(d, str):
|
|
273
|
+
if s != d:
|
|
274
|
+
report.error(f"key {key}", f"non-text value changed: {s!r} -> {d!r}")
|
|
275
|
+
continue
|
|
276
|
+
compare_tokens(report, f"key {key}", s, d)
|
|
277
|
+
if s.strip() and s == d and re.search(r"[A-Za-z]{3}", s):
|
|
278
|
+
report.warn(f"key {key}", "same as source (untranslated?)")
|
|
279
|
+
check_style(report, f"key {key}", d)
|
|
280
|
+
for key in dst:
|
|
281
|
+
if key not in src and not skip(key) and not re.search(r"\[other\]$", key):
|
|
282
|
+
report.error(f"key {key}", "not in source")
|
|
283
|
+
if list(k for k in src if k in dst) != list(k for k in dst if k in src):
|
|
284
|
+
report.warn("file", "keys are in a different order from the source")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------- single-file formats ----------
|
|
288
|
+
|
|
289
|
+
def po_entries(text):
|
|
290
|
+
entries, cur, field = [], {}, None
|
|
291
|
+
for line in text.replace("\r\n", "\n").split("\n") + [""]:
|
|
292
|
+
line = line.strip()
|
|
293
|
+
if not line:
|
|
294
|
+
if cur:
|
|
295
|
+
entries.append(cur)
|
|
296
|
+
cur, field = {}, None
|
|
297
|
+
continue
|
|
298
|
+
if line.startswith("#"):
|
|
299
|
+
if line.startswith("#,"):
|
|
300
|
+
cur["flags"] = line
|
|
301
|
+
continue
|
|
302
|
+
m = re.match(r'^(msgctxt|msgid_plural|msgid|msgstr(?:\[\d+\])?)\s+"(.*)"$', line)
|
|
303
|
+
if m:
|
|
304
|
+
field = m.group(1)
|
|
305
|
+
cur[field] = unescape_po(m.group(2))
|
|
306
|
+
elif line.startswith('"') and field:
|
|
307
|
+
cur[field] += unescape_po(line[1:-1])
|
|
308
|
+
return entries
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def unescape_po(s):
|
|
312
|
+
return s.replace('\\"', '"').replace("\\\\", "\\")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def check_po(report, path):
|
|
316
|
+
for e in po_entries(Path(path).read_text(encoding="utf-8")):
|
|
317
|
+
msgid = e.get("msgid", "")
|
|
318
|
+
if not msgid:
|
|
319
|
+
continue # header
|
|
320
|
+
where = f"msgid {msgid[:40]!r}"
|
|
321
|
+
if "msgid_plural" in e:
|
|
322
|
+
targets = [v for k, v in e.items() if k.startswith("msgstr[")]
|
|
323
|
+
source = e["msgid_plural"]
|
|
324
|
+
else:
|
|
325
|
+
targets = [e.get("msgstr", "")]
|
|
326
|
+
source = msgid
|
|
327
|
+
for t in targets:
|
|
328
|
+
if not t:
|
|
329
|
+
report.warn(where, "not translated")
|
|
330
|
+
continue
|
|
331
|
+
compare_tokens(report, where, source, t)
|
|
332
|
+
check_style(report, where, t)
|
|
333
|
+
if "fuzzy" in e.get("flags", ""):
|
|
334
|
+
report.warn(where, "still marked fuzzy")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def element_text(el):
|
|
338
|
+
"""Text plus inline elements, so tags like <x id="1"/> are compared too."""
|
|
339
|
+
if el is None:
|
|
340
|
+
return ""
|
|
341
|
+
parts = [el.text or ""]
|
|
342
|
+
for child in el:
|
|
343
|
+
tag = child.tag.split("}")[-1]
|
|
344
|
+
parts.append(f"<{tag} id={child.get('id', '')}>")
|
|
345
|
+
parts.append(element_text(child))
|
|
346
|
+
parts.append(child.tail or "")
|
|
347
|
+
return "".join(parts)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def check_xliff(report, path):
|
|
351
|
+
try:
|
|
352
|
+
root = ET.parse(path).getroot()
|
|
353
|
+
except ET.ParseError as e:
|
|
354
|
+
report.error("file", f"invalid XML: {e}")
|
|
355
|
+
return
|
|
356
|
+
def local(el):
|
|
357
|
+
return el.tag.split("}")[-1]
|
|
358
|
+
for el in root.iter():
|
|
359
|
+
if local(el) not in ("trans-unit", "segment"):
|
|
360
|
+
continue
|
|
361
|
+
uid = el.get("id") or "?"
|
|
362
|
+
source = next((c for c in el if local(c) == "source"), None)
|
|
363
|
+
target = next((c for c in el if local(c) == "target"), None)
|
|
364
|
+
where = f"unit {uid}"
|
|
365
|
+
if target is None or not element_text(target).strip():
|
|
366
|
+
report.warn(where, "no target")
|
|
367
|
+
continue
|
|
368
|
+
s, t = element_text(source), element_text(target)
|
|
369
|
+
compare_tokens(report, where, s, t)
|
|
370
|
+
inline_s = Counter(re.findall(r"<\w+ id=[^>]*>", s))
|
|
371
|
+
inline_t = Counter(re.findall(r"<\w+ id=[^>]*>", t))
|
|
372
|
+
if inline_s != inline_t:
|
|
373
|
+
report.error(where, "inline tags differ between source and target")
|
|
374
|
+
check_style(report, where, t)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def check_csv(report, path, opts):
|
|
378
|
+
with open(path, encoding="utf-8-sig", newline="") as f:
|
|
379
|
+
sample = f.read(4096)
|
|
380
|
+
f.seek(0)
|
|
381
|
+
try:
|
|
382
|
+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
|
|
383
|
+
except csv.Error:
|
|
384
|
+
dialect = csv.excel
|
|
385
|
+
rows = list(csv.DictReader(f, dialect=dialect))
|
|
386
|
+
if not rows:
|
|
387
|
+
report.error("file", "no rows")
|
|
388
|
+
return
|
|
389
|
+
cols = {c.lower(): c for c in rows[0].keys() if c}
|
|
390
|
+
src_col = pick_col(cols, opts.source_col, ["english", "en", "source", "en-us"])
|
|
391
|
+
dst_col = pick_col(cols, opts.target_col, ["thai", "th", "target", "th-th"])
|
|
392
|
+
if not src_col or not dst_col:
|
|
393
|
+
report.error("file", f"cannot find source/target columns in {list(rows[0].keys())}")
|
|
394
|
+
return
|
|
395
|
+
max_col = pick_col(cols, None, ["maxlength", "max length", "max_length", "limit"])
|
|
396
|
+
id_col = pick_col(cols, None, ["id", "key", "string id"]) or src_col
|
|
397
|
+
for n, row in enumerate(rows, 2):
|
|
398
|
+
where = f"row {n} ({row.get(id_col, '')})"
|
|
399
|
+
s, d = row.get(src_col) or "", row.get(dst_col) or ""
|
|
400
|
+
if not d.strip():
|
|
401
|
+
if s.strip():
|
|
402
|
+
report.warn(where, "not translated")
|
|
403
|
+
continue
|
|
404
|
+
compare_tokens(report, where, s, d)
|
|
405
|
+
if max_col and (row.get(max_col) or "").strip().isdigit():
|
|
406
|
+
limit = int(row[max_col])
|
|
407
|
+
if visible_len(d) > limit:
|
|
408
|
+
report.warn(where, f"{visible_len(d)} characters (max {limit})")
|
|
409
|
+
check_style(report, where, d)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def pick_col(cols, wanted, defaults):
|
|
413
|
+
if wanted:
|
|
414
|
+
return cols.get(wanted.lower())
|
|
415
|
+
for name in defaults:
|
|
416
|
+
if name in cols:
|
|
417
|
+
return cols[name]
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# ---------- main ----------
|
|
422
|
+
|
|
423
|
+
def main(argv=None):
|
|
424
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
425
|
+
ap.add_argument("files", nargs="+")
|
|
426
|
+
ap.add_argument("--max-line", type=int, default=35)
|
|
427
|
+
ap.add_argument("--max-lines", type=int, default=2)
|
|
428
|
+
ap.add_argument("--max-cps", type=float, default=20)
|
|
429
|
+
ap.add_argument("--source-col")
|
|
430
|
+
ap.add_argument("--target-col")
|
|
431
|
+
ap.add_argument("--strict", action="store_true")
|
|
432
|
+
opts = ap.parse_args(argv)
|
|
433
|
+
|
|
434
|
+
report = Report()
|
|
435
|
+
ext = Path(opts.files[-1]).suffix.lower()
|
|
436
|
+
single = {".po": check_po, ".xlf": check_xliff, ".xliff": check_xliff}
|
|
437
|
+
if len(opts.files) == 1:
|
|
438
|
+
path = opts.files[0]
|
|
439
|
+
if ext in single:
|
|
440
|
+
single[ext](report, path)
|
|
441
|
+
elif ext in (".csv", ".tsv"):
|
|
442
|
+
check_csv(report, path, opts)
|
|
443
|
+
else:
|
|
444
|
+
ap.error(f"{ext} needs two files: SOURCE TRANSLATION")
|
|
445
|
+
elif len(opts.files) == 2:
|
|
446
|
+
src_path, dst_path = opts.files
|
|
447
|
+
read = lambda p: Path(p).read_text(encoding="utf-8-sig")
|
|
448
|
+
if ext == ".srt":
|
|
449
|
+
check_subtitles(report, parse_srt(read(src_path)), parse_srt(read(dst_path)), opts)
|
|
450
|
+
elif ext == ".vtt":
|
|
451
|
+
check_subtitles(report, parse_vtt(read(src_path)), parse_vtt(read(dst_path)), opts)
|
|
452
|
+
elif ext in (".ass", ".ssa"):
|
|
453
|
+
check_subtitles(report, parse_ass(read(src_path)), parse_ass(read(dst_path)), opts, ass=True)
|
|
454
|
+
elif ext in (".json", ".arb"):
|
|
455
|
+
src, dst = load_json(report, src_path, "source"), load_json(report, dst_path, "translation")
|
|
456
|
+
if src is not None and dst is not None:
|
|
457
|
+
skip = (lambda k: k.split(".")[0].startswith("@")) if ext == ".arb" else (lambda k: False)
|
|
458
|
+
compare_maps(report, flatten_json(src), flatten_json(dst), skip)
|
|
459
|
+
elif ext == ".strings":
|
|
460
|
+
compare_maps(report, strings_pairs(read(src_path)), strings_pairs(read(dst_path)))
|
|
461
|
+
elif ext == ".xml":
|
|
462
|
+
src = android_strings(report, src_path, "source")
|
|
463
|
+
dst = android_strings(report, dst_path, "translation")
|
|
464
|
+
if src is not None and dst is not None:
|
|
465
|
+
compare_maps(report, src, dst)
|
|
466
|
+
elif ext in single:
|
|
467
|
+
single[ext](report, dst_path)
|
|
468
|
+
else:
|
|
469
|
+
ap.error(f"unsupported file type: {ext}")
|
|
470
|
+
else:
|
|
471
|
+
ap.error("give one or two files")
|
|
472
|
+
|
|
473
|
+
for e in report.errors:
|
|
474
|
+
print(f"ERROR {e}")
|
|
475
|
+
for w in report.warnings:
|
|
476
|
+
print(f"WARN {w}")
|
|
477
|
+
print(f"{len(report.errors)} error(s), {len(report.warnings)} warning(s)")
|
|
478
|
+
if report.errors or (opts.strict and report.warnings):
|
|
479
|
+
return 1
|
|
480
|
+
return 0
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
if __name__ == "__main__":
|
|
484
|
+
sys.exit(main())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: script-th
|
|
3
|
+
description: Writes and polishes Thai dialogue for AI-generated short dramas and Thai AI voice-over. Turns an idea or outline into a script, rewrites stiff Thai lines so characters sound like real Thai people, splits scenes into shots that fit the clip length of AI video tools (Veo, Kling, Hailuo, Runway, Seedance and others), writes per-shot prompts with fixed character descriptions, and prepares TTS-ready voice scripts (numbers as words, unambiguous spelling, pauses, voice direction). Explains in the user's language, with back-translations for non-Thai readers. Modes - เขียนบท (write), เกลาบท (polish, default when a script is given), ทำพรอมต์ (shot prompts) and เตรียมบทพากย์ (voice script). Use when the user asks to เขียนบทละครสั้น, เขียนบทพูด, เกลาบท, ทำบทพากย์, พากย์เสียง AI, ทำพรอมต์วิดีโอ, ละครสั้น AI, ซีรีส์แนวตั้ง, or write Thai dialogue, scripts, voice-over or video prompts for AI video. Thai dialogue only. Not for impersonating real people.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# script-th
|
|
8
|
+
|
|
9
|
+
ช่วยเขียนและเกลาบทพูดภาษาไทย สำหรับละครสั้นที่สร้างด้วยเว็บสร้างวิดีโอ AI และงานพากย์เสียงด้วย AI ภาษาไทย ให้ตัวละครพูดเหมือนคนไทยจริง และให้เครื่องมือ AI เอาบทไปใช้ได้ผล
|
|
10
|
+
|
|
11
|
+
งานนี้ต้องได้ 2 อย่างพร้อมกัน
|
|
12
|
+
|
|
13
|
+
1. **บทที่ดี:** ตัวละครแต่ละตัวมีน้ำเสียงของตัวเอง พูดแบบที่คนไทยพูด ไม่ใช่ภาษาเขียนหรือภาษาแปล และเรื่องเดินเร็วพอสำหรับคลิปสั้น
|
|
14
|
+
2. **ใช้กับเครื่องมือได้:** บทพูดแต่ละช็อตยาวพอดีกับคลิป หน้าตาตัวละครไม่เปลี่ยนระหว่างช็อต และเสียง AI อ่านทุกคำได้ถูก
|
|
15
|
+
|
|
16
|
+
## ใช้เมื่อไหร่
|
|
17
|
+
|
|
18
|
+
- ผู้ใช้มีไอเดีย เรื่องย่อ หรือโครงเรื่อง แล้วอยากได้บทละครสั้น
|
|
19
|
+
- ผู้ใช้มีบทภาษาไทยอยู่แล้ว (เขียนเองหรือให้ AI เขียน) แล้วอยากให้บทพูดเป็นธรรมชาติขึ้น
|
|
20
|
+
- ผู้ใช้อยากแปลงบทเป็นพรอมต์ทีละช็อต สำหรับเว็บสร้างวิดีโอ
|
|
21
|
+
- ผู้ใช้จะเอาบทไปให้ AI พากย์เสียง แล้วอยากให้อ่านถูกและฟังเป็นธรรมชาติ
|
|
22
|
+
|
|
23
|
+
**ไม่ใช้เมื่อ**
|
|
24
|
+
|
|
25
|
+
- ต้องการแปลบทหรือซับจากภาษาอังกฤษ ใช้ skill `localize-th` ก่อน แล้วค่อยใช้ skill นี้เกลาหรือทำพรอมต์
|
|
26
|
+
- ข้อความไม่มีคนพูด เช่น โพสต์ บทความ ใช้ skill `humanizer-th`
|
|
27
|
+
|
|
28
|
+
## ไฟล์อ้างอิง
|
|
29
|
+
|
|
30
|
+
เปิดอ่านเมื่อจำเป็น ไม่ต้องอ่านทุกไฟล์ทุกครั้ง
|
|
31
|
+
|
|
32
|
+
| ไฟล์ | ใช้เมื่อ |
|
|
33
|
+
|---|---|
|
|
34
|
+
| [references/dialogue.md](references/dialogue.md) | ทุกครั้งที่เขียนหรือเกลาบท หลักการเขียนบทพูด โครงละครสั้น และรูปแบบบทพูดแบบ AI SC01–SC12 |
|
|
35
|
+
| [references/characters.md](references/characters.md) | ตั้งตัวละคร เลือกสรรพนามและคำลงท้ายตามความสัมพันธ์ |
|
|
36
|
+
| [references/video-prompts.md](references/video-prompts.md) | แบ่งช็อตและเขียนพรอมต์สำหรับเว็บสร้างวิดีโอ |
|
|
37
|
+
| [references/voice.md](references/voice.md) | เตรียมบทให้ AI พากย์เสียง ตัวเลข คำอ่านยาก จังหวะ อารมณ์ |
|
|
38
|
+
| [references/patterns.md](references/patterns.md) | ตรวจรูปแบบภาษาแบบ AI TH01–TH29 ในคำบรรยายและข้อความบนจอ |
|
|
39
|
+
| [references/registers.md](references/registers.md) | ระดับภาษาของคำบรรยายและข้อความที่ไม่ใช่บทพูด |
|
|
40
|
+
| [references/style.md](references/style.md) | ผู้ใช้ส่งบทเก่าของตัวเองหรือตอนก่อนมา แล้วอยากให้ตอนใหม่เป็นสไตล์เดิม |
|
|
41
|
+
|
|
42
|
+
## เลือกโหมด
|
|
43
|
+
|
|
44
|
+
| โหมด | เมื่อผู้ใช้พูดว่า | ได้อะไร |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `เขียนบท` (write) | "เขียนบทละครสั้นเรื่อง..." "ช่วยคิดบทจากพล็อตนี้" | ตารางตัวละคร + บทแบ่งฉากและช็อต |
|
|
47
|
+
| `เกลาบท` (polish, ค่าเริ่มต้นเมื่อส่งบทมา) | "เกลาบทให้หน่อย" "บทพูดแข็ง ช่วยแก้" | บทที่แก้แล้ว โครงเรื่องเดิม + สรุปสิ่งที่เปลี่ยน |
|
|
48
|
+
| `ทำพรอมต์` (prompts) | "ทำพรอมต์ Veo ให้หน่อย" "แปลงบทเป็นพรอมต์วิดีโอ" | การ์ดช็อต พร้อมพรอมต์ที่คัดลอกไปวางได้ทันที |
|
|
49
|
+
| `เตรียมบทพากย์` (voice) | "ทำบทให้ AI พากย์" "เสียง AI อ่านผิด ช่วยแก้" | บทพากย์ที่ AI อ่านถูก + ทิศทางเสียง |
|
|
50
|
+
|
|
51
|
+
ผู้ใช้ขอหลายโหมดในครั้งเดียวได้ เช่น เขียนบทแล้วทำพรอมต์ต่อ ให้ทำตามลำดับ เขียนบท → เกลาบท → ทำพรอมต์ → เตรียมบทพากย์
|
|
52
|
+
|
|
53
|
+
## ขั้นตอน
|
|
54
|
+
|
|
55
|
+
### ขั้นที่ 1: รับโจทย์
|
|
56
|
+
|
|
57
|
+
ดูว่าผู้ใช้ให้ข้อมูลเหล่านี้มาหรือยัง
|
|
58
|
+
|
|
59
|
+
| เรื่อง | ค่าเริ่มต้นถ้าไม่ได้บอก |
|
|
60
|
+
|---|---|
|
|
61
|
+
| แนวเรื่องและกลุ่มผู้ชม | เดาจากพล็อต แล้วบอกในบันทึก |
|
|
62
|
+
| ความยาวต่อตอน และจำนวนตอน | 1 ตอน ยาว 60–90 วินาที |
|
|
63
|
+
| สัดส่วนภาพ | แนวตั้ง 9:16 |
|
|
64
|
+
| เครื่องมือสร้างวิดีโอ และความยาวคลิปต่อครั้ง | ไม่ระบุเครื่องมือ คลิปละ 8 วินาที |
|
|
65
|
+
| วิธีใส่เสียง | ถามผู้ใช้ ถ้าต้องทำพรอมต์หรือบทพากย์ (ดู `video-prompts.md` หัวข้อวิธีใส่เสียง) |
|
|
66
|
+
|
|
67
|
+
ถ้าข้อมูลขาดจนเขียนผิดทางได้ ให้ถาม**คำถามเดียว**ที่สำคัญที่สุด ที่เหลือใช้ค่าเริ่มต้นแล้วบอกในบันทึก
|
|
68
|
+
|
|
69
|
+
### ขั้นที่ 2: ตั้งตัวละคร
|
|
70
|
+
|
|
71
|
+
ทำตารางตัวละครก่อนเขียนบรรทัดแรก ใช้ตารางนี้ตลอดทุกตอน
|
|
72
|
+
|
|
73
|
+
- ชื่อ เพศ อายุ บุคลิก และวิธีพูด
|
|
74
|
+
- สรรพนามที่ใช้กับแต่ละคน และคำลงท้าย ตาม `characters.md`
|
|
75
|
+
- **คำบรรยายหน้าตาภาษาอังกฤษ** 1–2 ประโยค ที่จะคัดลอกไปใส่ทุกพรอมต์แบบไม่เปลี่ยนแม้แต่คำเดียว (ทำเฉพาะเมื่อจะทำพรอมต์)
|
|
76
|
+
- **ลักษณะเสียง** เช่น เสียงทุ้ม พูดช้า เสียงใส พูดเร็ว (ทำเฉพาะเมื่อจะทำบทพากย์)
|
|
77
|
+
|
|
78
|
+
### ขั้นที่ 3: เขียนหรือเกลาบท
|
|
79
|
+
|
|
80
|
+
- ใช้หลักใน `dialogue.md` เขียนแบบที่คนไทยพูด ไล่หารูปแบบ SC01–SC12
|
|
81
|
+
- `เขียนบท`: เปิดด้วยฉากที่ดึงคนดูใน 3 วินาทีแรก จบตอนด้วยเรื่องที่ทำให้อยากดูตอนต่อไป
|
|
82
|
+
- `เกลาบท`: คงเรื่อง ฉาก ลำดับเหตุการณ์ และตัวละครเดิม แก้เฉพาะวิธีพูด ถ้าเห็นปัญหาโครงเรื่อง ให้เสนอในบันทึก ไม่แก้เอง
|
|
83
|
+
- **อย่าสร้างรูปแบบใหม่ระหว่างแก้** เช่น ตัดคำคมสำเร็จรูปออกแล้วใส่คำคมอันใหม่แทน
|
|
84
|
+
|
|
85
|
+
### ขั้นที่ 4: แบ่งช็อต
|
|
86
|
+
|
|
87
|
+
ทำเมื่อผู้ใช้ต้องการพรอมต์ บทพากย์ หรือบอกเครื่องมือมา
|
|
88
|
+
|
|
89
|
+
- หนึ่งช็อตไม่ยาวเกินคลิปที่เครื่องมือสร้างได้ในครั้งเดียว
|
|
90
|
+
- บทพูดในช็อตต้องพูดจบในเวลานั้น ใช้หลักนับพยางค์ใน `voice.md`
|
|
91
|
+
- หนึ่งช็อตควรมีคนพูดคนเดียว ถ้าต้องโต้ตอบกัน ให้แยกเป็นช็อตละคน
|
|
92
|
+
|
|
93
|
+
### ขั้นที่ 5: ทำพรอมต์หรือบทพากย์
|
|
94
|
+
|
|
95
|
+
- `ทำพรอมต์`: ตาม `video-prompts.md`
|
|
96
|
+
- `เตรียมบทพากย์`: ตาม `voice.md` ถ้าข้อความที่ให้ AI อ่านต่างจากข้อความที่จะขึ้นซับ (เช่น ตัวเลขเขียนเป็นคำ) ให้แยกสองช่อง
|
|
97
|
+
|
|
98
|
+
### ขั้นที่ 6: ตรวจทาน
|
|
99
|
+
|
|
100
|
+
- [ ] อ่านบทออกเสียงในใจแล้วไม่สะดุด ตัวละครแต่ละตัวพูดไม่เหมือนกัน
|
|
101
|
+
- [ ] สรรพนามและคำลงท้ายตรงตามตารางตัวละครตลอดเรื่อง
|
|
102
|
+
- [ ] บทพูดทุกช็อตพูดจบทันเวลา
|
|
103
|
+
- [ ] คำบรรยายหน้าตาตัวละครเหมือนกันทุกพรอมต์
|
|
104
|
+
- [ ] ไม่มีอีโมจิ ไม่มี "!" ในบทพูดและบทพากย์ (ใช้คำลงท้ายและทิศทางเสียงแทน ดู TH29)
|
|
105
|
+
- [ ] `เกลาบท`: เรื่องและข้อมูลในบทเหมือนเดิมครบ
|
|
106
|
+
|
|
107
|
+
## ข้อห้าม
|
|
108
|
+
|
|
109
|
+
1. ห้ามใช้คนจริง เช่น ดารา นักการเมือง หรือคนทั่วไป เป็นตัวละครที่พูดหรือทำสิ่งที่เขาไม่ได้ทำจริง เว้นแต่ผู้ใช้เป็นคนนั้นเองหรือได้รับอนุญาต ถ้าผู้ใช้ขอ ให้เสนอตัวละครสมมติแทน
|
|
110
|
+
2. ห้ามเขียนบทที่ตั้งใจให้คนดูเชื่อว่าเป็นเหตุการณ์จริง เพื่อหลอกลวง เช่น ข่าวปลอม คลิปหลอกให้โอนเงิน หรือรีวิวปลอม
|
|
111
|
+
3. `เกลาบท` ห้ามเปลี่ยนเรื่อง ตัวละคร และข้อมูลสำคัญของผู้ใช้
|
|
112
|
+
4. คำหยาบ ความรุนแรง และเรื่องทางเพศ ให้อยู่ในระดับที่ผู้ใช้กำหนดและแพลตฟอร์มที่จะลงยอมรับ ถ้าไม่ได้กำหนด ให้เขียนแบบที่ลง TikTok หรือ YouTube ได้
|
|
113
|
+
5. ห้ามใช้ตัวละคร ชื่อเรื่อง หรือบทจากงานที่มีลิขสิทธิ์ของคนอื่น ถ้าผู้ใช้ขอ ให้เขียนเรื่องใหม่ที่ได้แรงบันดาลใจจากแนวเดียวกัน
|
|
114
|
+
|
|
115
|
+
## รูปแบบคำตอบ
|
|
116
|
+
|
|
117
|
+
### ภาษาของคำตอบ
|
|
118
|
+
|
|
119
|
+
- **บทพูด บทพากย์ และข้อความบนจอเป็นภาษาไทยเสมอ** ส่วนพรอมต์วิดีโอเป็นภาษาอังกฤษตาม `video-prompts.md` เหมือนเดิม
|
|
120
|
+
- **ส่วนที่อธิบายให้ผู้ใช้อ่าน ใช้ภาษาเดียวกับคำขอ:** ตารางตัวละคร คำบรรยายฉาก บันทึก สรุปสิ่งที่เปลี่ยน คำถาม และหัวข้อในแบบคำตอบด้านล่าง ถ้าผู้ใช้สั่งเป็นภาษาอังกฤษ ให้เขียนส่วนนี้เป็นภาษาอังกฤษทั้งหมด เช่น `บันทึก:` เป็น `Notes:` ถ้าสั่งเป็นภาษาไทยหรือไทยปนอังกฤษ ใช้ภาษาไทย
|
|
121
|
+
- **คำไทยที่ยกมาอ้างในคำอธิบาย** คงเป็นภาษาไทย แล้วบอกความหมายในวงเล็บ เช่น "พี่" (calls him "older brother", polite and close) รหัส SCxx คงไว้เหมือนเดิม
|
|
122
|
+
- **คำแปลกลับ:** ถ้าผู้ใช้สั่งเป็นภาษาอื่นที่ไม่ใช่ไทย หรือบอกว่าอ่านไทยไม่ออก ให้แนบคำแปลกลับของบทพูดทุกบรรทัดไว้ท้ายตอน ใต้หัวข้อ `Back-translation:` เรียงตามฉาก ถ้าบทยาวหลายตอน ให้แนบทีละตอน เขียนคำแปลกลับตามความหมายจริงของภาษาไทย ไม่ต้องเกลาให้สวย เพราะมีไว้ให้ผู้ใช้ตรวจว่าความหมายยังตรง และบอกสิ่งที่ภาษาไทยใส่เพิ่มไว้ในวงเล็บ เช่น `(polite, female speaker)` `(calls him "older brother")`
|
|
123
|
+
|
|
124
|
+
### `เขียนบท` และ `เกลาบท`
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
ตัวละคร
|
|
128
|
+
| ตัวละคร | อายุ | แทนตัวเอง | เรียกอีกฝ่าย | คำลงท้าย | วิธีพูด |
|
|
129
|
+
|---|---|---|---|---|---|
|
|
130
|
+
| ... |
|
|
131
|
+
|
|
132
|
+
ตอนที่ 1: [ชื่อตอน] (ประมาณ 75 วินาที)
|
|
133
|
+
|
|
134
|
+
ฉาก 1 ภายใน ร้านกาแฟ กลางวัน
|
|
135
|
+
[คำบรรยายสั้นๆ ว่าเห็นอะไร]
|
|
136
|
+
|
|
137
|
+
มะลิ: (วางแก้วแรงๆ) ...
|
|
138
|
+
ต้น: ...
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
บันทึก:
|
|
142
|
+
- ...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- `เกลาบท` ให้เพิ่ม `สิ่งที่เปลี่ยน:` 3–5 ข้อพร้อมรหัส เช่น `- ตัดคำคมสำเร็จรูปในฉาก 3 (SC06)`
|
|
146
|
+
- ถ้าบทดีอยู่แล้ว ให้บอกตรงๆ และแก้น้อยที่สุด
|
|
147
|
+
|
|
148
|
+
### `ทำพรอมต์`
|
|
149
|
+
|
|
150
|
+
การ์ดช็อตทีละช็อต ตามแบบใน `video-prompts.md` หัวข้อการ์ดช็อต
|
|
151
|
+
|
|
152
|
+
### `เตรียมบทพากย์`
|
|
153
|
+
|
|
154
|
+
ตารางบทพากย์ตามแบบใน `voice.md` หัวข้อรูปแบบบทพากย์
|
|
155
|
+
|
|
156
|
+
## กรณีพิเศษ
|
|
157
|
+
|
|
158
|
+
- **ผู้ใช้ส่งบทที่ AI อื่นเขียนมา:** ใช้ `เกลาบท` ตามปกติ ส่วนใหญ่จะเจอ SC01 SC02 SC06 มาก
|
|
159
|
+
- **ผู้ใช้ส่งบทเก่าของตัวเอง หรือตอนก่อนของเรื่องเดียวกันมา:** ทำโปรไฟล์ตาม `style.md` แล้วเขียนหรือเกลาให้เข้ากับของเดิม ทั้งสรรพนามของตัวละคร ความแรงของภาษา และจังหวะของฉาก ถ้าบทเดิมมีรูปแบบ SC เป็นประจำ เช่น ตัวละครบอกความรู้สึกตรงๆ (SC01) ไม่ต้องเลียน ให้บอกในบันทึก
|
|
160
|
+
- **ผู้ใช้อยากได้ภาษาถิ่น** (อีสาน เหนือ ใต้): เขียนได้ แต่บอกในบันทึกว่าให้คนที่พูดภาษาถิ่นนั้นตรวจอีกรอบ และเตือนว่าเสียง AI หลายตัวอ่านภาษาถิ่นไม่ถูก
|
|
161
|
+
- **ผู้ใช้ส่งบทภาษาอังกฤษมา:** แนะนำให้แปลด้วย `localize-th` ก่อน ถ้าผู้ใช้อยากให้ทำในขั้นเดียว ทำได้ โดยแปลแบบเขียนบทใหม่ ไม่ใช่แปลทีละบรรทัด
|
|
162
|
+
- **เครื่องมือสร้างวิดีโออ่านไทยผิด หรือใส่ตัวหนังสือมั่วบนจอ:** ดู `video-prompts.md` หัวข้อปัญหาที่พบบ่อย
|