@rydr/game-sdk 3.4.0 → 3.6.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/dist/client/PlatformClient.d.ts +19 -0
- package/dist/client/PlatformClient.d.ts.map +1 -1
- package/dist/client/PlatformClient.js +14 -2
- package/dist/client/PlatformClient.js.map +1 -1
- package/dist/conversations/hash.d.ts +34 -0
- package/dist/conversations/hash.d.ts.map +1 -0
- package/dist/conversations/hash.js +41 -0
- package/dist/conversations/hash.js.map +1 -0
- package/dist/conversations/index.d.ts +160 -0
- package/dist/conversations/index.d.ts.map +1 -0
- package/dist/conversations/index.js +422 -0
- package/dist/conversations/index.js.map +1 -0
- package/dist/conversations/voices.d.ts +16 -0
- package/dist/conversations/voices.d.ts.map +1 -0
- package/dist/conversations/voices.js +36 -0
- package/dist/conversations/voices.js.map +1 -0
- package/dist/host/PlatformHost.d.ts +14 -0
- package/dist/host/PlatformHost.d.ts.map +1 -1
- package/dist/host/PlatformHost.js +11 -0
- package/dist/host/PlatformHost.js.map +1 -1
- package/dist/protocol/boards.d.ts +16 -0
- package/dist/protocol/boards.d.ts.map +1 -1
- package/dist/protocol/boards.js.map +1 -1
- package/dist/protocol/characters.d.ts +13 -0
- package/dist/protocol/characters.d.ts.map +1 -1
- package/dist/protocol/conversations.d.ts +99 -0
- package/dist/protocol/conversations.d.ts.map +1 -0
- package/dist/protocol/conversations.js +23 -0
- package/dist/protocol/conversations.js.map +1 -0
- package/dist/protocol/guards.d.ts.map +1 -1
- package/dist/protocol/guards.js +1 -0
- package/dist/protocol/guards.js.map +1 -1
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.d.ts.map +1 -1
- package/dist/protocol/index.js +1 -0
- package/dist/protocol/index.js.map +1 -1
- package/dist/protocol/messages.d.ts +33 -2
- package/dist/protocol/messages.d.ts.map +1 -1
- package/dist/protocol/version.d.ts +2 -2
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +8 -2
- package/dist/protocol/version.js.map +1 -1
- package/dist/ui/dialogue-card.d.ts +18 -2
- package/dist/ui/dialogue-card.d.ts.map +1 -1
- package/dist/ui/dialogue-card.js +35 -9
- package/dist/ui/dialogue-card.js.map +1 -1
- package/dist/voiceover-editor/index.d.ts +60 -0
- package/dist/voiceover-editor/index.d.ts.map +1 -0
- package/dist/voiceover-editor/index.js +641 -0
- package/dist/voiceover-editor/index.js.map +1 -0
- package/package.json +9 -1
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { CONVERSATIONS_COLLECTION, VOICE_CAST_COLLECTION, DEFAULT_VOICEOVER_SPEED, synthDirection, } from "../protocol/conversations.js";
|
|
2
|
+
import { lineHash } from "../conversations/hash.js";
|
|
3
|
+
import { GEMINI_VOICES } from "../conversations/voices.js";
|
|
4
|
+
import { syncConversations } from "../conversations/index.js";
|
|
5
|
+
const CSS = `
|
|
6
|
+
.rydr-vo{position:fixed;inset:0;display:grid;grid-template-columns:300px 1fr;font:14px/1.5 system-ui,sans-serif;color:#e7e9ee;background:#0d0f14}
|
|
7
|
+
.rydr-vo *{box-sizing:border-box}
|
|
8
|
+
.rydr-vo-side{border-right:1px solid #232733;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:6px}
|
|
9
|
+
.rydr-vo-side h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b93a7;margin:4px 0 8px}
|
|
10
|
+
.rydr-vo-tabs{display:flex;gap:6px;margin-bottom:10px}
|
|
11
|
+
.rydr-vo-tab{flex:1;text-align:center;padding:8px;border-radius:8px;cursor:pointer;border:1px solid #232733;color:#9aa4b8}
|
|
12
|
+
.rydr-vo-tab.sel{background:#1b2130;border-color:#31405f;color:#fff}
|
|
13
|
+
.rydr-vo-conv{padding:8px 10px;border-radius:8px;cursor:pointer;border:1px solid transparent;color:#cdd3e0}
|
|
14
|
+
.rydr-vo-conv:hover{background:#161a22}
|
|
15
|
+
.rydr-vo-conv.sel{background:#1b2130;border-color:#31405f}
|
|
16
|
+
.rydr-vo-conv small{display:block;color:#6f7889}
|
|
17
|
+
.rydr-vo-main{overflow:auto;padding:16px 20px;display:flex;flex-direction:column;gap:14px}
|
|
18
|
+
.rydr-vo-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
19
|
+
.rydr-vo-head input.title{font-size:18px;font-weight:600;background:#12151d;border:1px solid #232733;border-radius:8px;color:#fff;padding:8px 10px;min-width:260px}
|
|
20
|
+
.rydr-vo-row{display:grid;grid-template-columns:150px 1fr auto;gap:10px;align-items:start;padding:10px;border:1px solid #232733;border-radius:10px;background:#11141b}
|
|
21
|
+
.rydr-vo-row textarea{width:100%;min-height:52px;resize:vertical;background:#0d0f14;border:1px solid #232733;border-radius:8px;color:#e7e9ee;padding:8px 10px;font:inherit}
|
|
22
|
+
.rydr-vo-row select,.rydr-vo-row input{width:100%;background:#0d0f14;border:1px solid #232733;border-radius:8px;color:#e7e9ee;padding:6px 8px;font:inherit}
|
|
23
|
+
.rydr-vo-row [disabled]{opacity:.7;cursor:default}
|
|
24
|
+
.rydr-vo-subrow{display:flex;gap:8px;margin-top:6px}
|
|
25
|
+
.rydr-vo-subrow input.style{flex:1}
|
|
26
|
+
.rydr-vo-subrow input.speed{width:96px}
|
|
27
|
+
.rydr-vo-tag{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#6f7889}
|
|
28
|
+
.rydr-vo-actions{display:flex;flex-direction:column;gap:6px;align-items:stretch;min-width:120px}
|
|
29
|
+
.rydr-vo button{background:#1c2433;border:1px solid #31405f;color:#dbe2f0;border-radius:8px;padding:7px 10px;cursor:pointer;font:inherit}
|
|
30
|
+
.rydr-vo button:hover{background:#243149}
|
|
31
|
+
.rydr-vo button.primary{background:#2f6df6;border-color:#2f6df6;color:#fff}
|
|
32
|
+
.rydr-vo button.ghost{background:transparent;border-color:#232733;color:#8b93a7}
|
|
33
|
+
.rydr-vo button:disabled{opacity:.5;cursor:default}
|
|
34
|
+
.rydr-vo-badge{font-size:12px;padding:2px 8px;border-radius:999px;white-space:nowrap;align-self:center}
|
|
35
|
+
.rydr-vo-badge.ok{background:#123524;color:#57d38c;border:1px solid #1c5738}
|
|
36
|
+
.rydr-vo-badge.missing{background:#2a2f3a;color:#9aa4b8;border:1px solid #3a4152}
|
|
37
|
+
.rydr-vo-badge.stale{background:#3a2c12;color:#e2b25a;border:1px solid #5c481c}
|
|
38
|
+
.rydr-vo-badge.novoice{background:#3a1620;color:#e2718f;border:1px solid #5c2436}
|
|
39
|
+
.rydr-vo-sync{border-radius:10px;padding:10px;margin-bottom:10px;border:1px solid #232733;background:#11141b}
|
|
40
|
+
.rydr-vo-sync button{width:100%;margin-top:8px}
|
|
41
|
+
.rydr-vo-sync .chip{font-weight:600;font-size:13px}
|
|
42
|
+
.rydr-vo-sync.insync .chip{color:#57d38c}
|
|
43
|
+
.rydr-vo-sync.push{border-color:#5c481c}.rydr-vo-sync.push .chip{color:#e2b25a}
|
|
44
|
+
.rydr-vo-sync.lossy{border-color:#5c2436;animation:rydr-vo-pulse 1.1s ease-in-out infinite}.rydr-vo-sync.lossy .chip{color:#e2718f}
|
|
45
|
+
.rydr-vo-sync details{margin-top:6px;color:#8b93a7;font-size:12px}
|
|
46
|
+
.rydr-vo-sync details pre{white-space:pre-wrap;margin:6px 0 0;color:#9aa4b8}
|
|
47
|
+
@keyframes rydr-vo-pulse{0%,100%{box-shadow:0 0 0 0 rgba(226,113,143,.0)}50%{box-shadow:0 0 0 3px rgba(226,113,143,.25)}}
|
|
48
|
+
@media (prefers-reduced-motion: reduce){.rydr-vo-sync.lossy{animation:none}}
|
|
49
|
+
.rydr-vo-cast{display:flex;flex-direction:column;gap:12px}
|
|
50
|
+
.rydr-vo-castrow{display:grid;grid-template-columns:180px 200px 1fr 110px;gap:12px;align-items:start;padding:12px;border:1px solid #232733;border-radius:10px;background:#11141b}
|
|
51
|
+
.rydr-vo-castrow .clabel{font-weight:600;color:#fff;padding-top:6px}
|
|
52
|
+
.rydr-vo-castrow select,.rydr-vo-castrow textarea,.rydr-vo-castrow input{width:100%;background:#0d0f14;border:1px solid #232733;border-radius:8px;color:#e7e9ee;padding:6px 8px;font:inherit}
|
|
53
|
+
.rydr-vo-castrow textarea{min-height:44px;resize:vertical}
|
|
54
|
+
.rydr-vo-empty{color:#6f7889;padding:40px;text-align:center}
|
|
55
|
+
.rydr-vo-note{color:#6f7889;font-size:12px}
|
|
56
|
+
`;
|
|
57
|
+
function injectCss() {
|
|
58
|
+
if (document.getElementById("rydr-vo-css"))
|
|
59
|
+
return;
|
|
60
|
+
const el = document.createElement("style");
|
|
61
|
+
el.id = "rydr-vo-css";
|
|
62
|
+
el.textContent = CSS;
|
|
63
|
+
document.head.appendChild(el);
|
|
64
|
+
}
|
|
65
|
+
function uid() {
|
|
66
|
+
return "c-" + Math.random().toString(36).slice(2, 9);
|
|
67
|
+
}
|
|
68
|
+
/** Mount the voice-over editor into `root`. */
|
|
69
|
+
export function mountVoiceoverEditor(root, opts) {
|
|
70
|
+
injectCss();
|
|
71
|
+
const { session } = opts;
|
|
72
|
+
// Treat `[]` exactly like omitted (Gap #2): a game that imports the defs module but declares nothing
|
|
73
|
+
// yet is NOT code-aware — no chip, plain authoring.
|
|
74
|
+
const definitions = opts.definitions?.length ? opts.definitions : [];
|
|
75
|
+
const codeAware = definitions.length > 0;
|
|
76
|
+
let characters = [];
|
|
77
|
+
const cast = new Map();
|
|
78
|
+
let conversations = []; // STORED docs (gamedata)
|
|
79
|
+
let selectedId = null;
|
|
80
|
+
let working = null;
|
|
81
|
+
let dirty = false;
|
|
82
|
+
let view = "conversations";
|
|
83
|
+
let previewAudio = null;
|
|
84
|
+
const wrap = document.createElement("div");
|
|
85
|
+
wrap.className = "rydr-vo";
|
|
86
|
+
const side = document.createElement("div");
|
|
87
|
+
side.className = "rydr-vo-side";
|
|
88
|
+
const main = document.createElement("div");
|
|
89
|
+
main.className = "rydr-vo-main";
|
|
90
|
+
wrap.append(side, main);
|
|
91
|
+
root.appendChild(wrap);
|
|
92
|
+
const charById = (id) => characters.find((c) => c.id === id);
|
|
93
|
+
const castOf = (id) => cast.get(id);
|
|
94
|
+
const defById = (id) => definitions.find((d) => d.id === id);
|
|
95
|
+
const storedById = (id) => conversations.find((c) => c.id === id);
|
|
96
|
+
const isCode = (id) => !!id && !!defById(id);
|
|
97
|
+
const effVoice = (l) => castOf(l.characterId)?.voice ?? charById(l.characterId)?.voice;
|
|
98
|
+
const effContext = (id) => castOf(id)?.context ?? charById(id)?.voiceStyle;
|
|
99
|
+
const effSpeed = (l) => l.speed ?? castOf(l.characterId)?.speed ?? DEFAULT_VOICEOVER_SPEED;
|
|
100
|
+
const effDirection = (l) => synthDirection(effContext(l.characterId), l.style);
|
|
101
|
+
async function statusOf(l) {
|
|
102
|
+
const voice = effVoice(l);
|
|
103
|
+
if (!voice)
|
|
104
|
+
return "novoice";
|
|
105
|
+
if (!l.audioUrl || !l.audioHash)
|
|
106
|
+
return "missing";
|
|
107
|
+
const hash = await lineHash({ text: l.text, voice, style: effDirection(l), speed: effSpeed(l), lang: l.lang });
|
|
108
|
+
return l.audioHash === hash ? "ok" : "stale";
|
|
109
|
+
}
|
|
110
|
+
/** All conversation ids (code ∪ stored). */
|
|
111
|
+
function allConvIds() {
|
|
112
|
+
const s = new Set();
|
|
113
|
+
for (const d of definitions)
|
|
114
|
+
s.add(d.id);
|
|
115
|
+
for (const c of conversations)
|
|
116
|
+
s.add(c.id);
|
|
117
|
+
return [...s];
|
|
118
|
+
}
|
|
119
|
+
/** Build the working copy for `id`: code text (read-only) overlaid with stored audio, or the stored
|
|
120
|
+
* doc for editor-authored conversations. Code-defined working copies carry `origin: "code"` so a
|
|
121
|
+
* Generate-before-Sync persist() writes a correctly-marked doc (Gap #1). */
|
|
122
|
+
function buildWorking(id) {
|
|
123
|
+
const def = defById(id);
|
|
124
|
+
const stored = storedById(id);
|
|
125
|
+
if (def) {
|
|
126
|
+
const storedLines = new Map((stored?.lines ?? []).map((l) => [l.id, l]));
|
|
127
|
+
const lines = def.lines.map((cl) => {
|
|
128
|
+
const s = storedLines.get(cl.id);
|
|
129
|
+
return { ...cl, voice: s?.voice, audioUrl: s?.audioUrl, audioHash: s?.audioHash, generatedAt: s?.generatedAt };
|
|
130
|
+
});
|
|
131
|
+
return { id, label: stored?.label ?? id, lines, origin: "code" };
|
|
132
|
+
}
|
|
133
|
+
return stored ? JSON.parse(JSON.stringify(stored)) : null;
|
|
134
|
+
}
|
|
135
|
+
function persist() {
|
|
136
|
+
if (!working)
|
|
137
|
+
return Promise.resolve();
|
|
138
|
+
return session.saveContent(CONVERSATIONS_COLLECTION, working.id, working).then(() => {
|
|
139
|
+
dirty = false;
|
|
140
|
+
const copy = JSON.parse(JSON.stringify(working));
|
|
141
|
+
const i = conversations.findIndex((c) => c.id === working.id);
|
|
142
|
+
if (i >= 0)
|
|
143
|
+
conversations[i] = copy;
|
|
144
|
+
else
|
|
145
|
+
conversations.push(copy);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async function generateLine(l) {
|
|
149
|
+
const voice = effVoice(l);
|
|
150
|
+
if (!voice)
|
|
151
|
+
throw new Error(`"${l.characterId}" has no voice — set one in the Characters tab.`);
|
|
152
|
+
const r = await session.generateVoiceover({ text: l.text, voice, style: effDirection(l), speed: effSpeed(l), lang: l.lang });
|
|
153
|
+
l.voice = voice;
|
|
154
|
+
l.audioUrl = r.url;
|
|
155
|
+
l.audioHash = r.hash;
|
|
156
|
+
l.generatedAt = Date.now();
|
|
157
|
+
dirty = true;
|
|
158
|
+
}
|
|
159
|
+
function preview(url) {
|
|
160
|
+
if (!previewAudio)
|
|
161
|
+
previewAudio = new Audio();
|
|
162
|
+
previewAudio.src = url;
|
|
163
|
+
void previewAudio.play().catch(() => { });
|
|
164
|
+
}
|
|
165
|
+
async function saveCast(id) {
|
|
166
|
+
const entry = cast.get(id);
|
|
167
|
+
if (entry && (entry.voice || entry.context || entry.speed != null)) {
|
|
168
|
+
await session.saveContent(VOICE_CAST_COLLECTION, id, entry);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
await session.deleteContent(VOICE_CAST_COLLECTION, id).catch(() => { });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// ── Sync diff (code vs stored) — pure + synchronous ──────────────────────────────────────────────
|
|
175
|
+
function lineDiffers(a, b) {
|
|
176
|
+
return a.text !== b.text || a.characterId !== b.characterId ||
|
|
177
|
+
(a.style ?? "") !== (b.style ?? "") || (a.speed ?? null) !== (b.speed ?? null) || a.lang !== b.lang;
|
|
178
|
+
}
|
|
179
|
+
function computeDiff() {
|
|
180
|
+
const codeIds = new Set(definitions.map((d) => d.id));
|
|
181
|
+
const pushConvos = [];
|
|
182
|
+
let newLines = 0, changedLines = 0, removedLines = 0;
|
|
183
|
+
const lines = [];
|
|
184
|
+
for (const d of definitions) {
|
|
185
|
+
const s = storedById(d.id);
|
|
186
|
+
if (!s) {
|
|
187
|
+
pushConvos.push(d.id);
|
|
188
|
+
newLines += d.lines.length;
|
|
189
|
+
lines.push(`${d.id} ⬆ new (${d.lines.length} line${d.lines.length === 1 ? "" : "s"})`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const sById = new Map(s.lines.map((l) => [l.id, l]));
|
|
193
|
+
let convChanged = false;
|
|
194
|
+
for (const cl of d.lines) {
|
|
195
|
+
const sl = sById.get(cl.id);
|
|
196
|
+
if (!sl) {
|
|
197
|
+
newLines++;
|
|
198
|
+
convChanged = true;
|
|
199
|
+
lines.push(`${d.id} · ${cl.id} ⬆ new line`);
|
|
200
|
+
}
|
|
201
|
+
else if (lineDiffers(cl, sl)) {
|
|
202
|
+
changedLines++;
|
|
203
|
+
convChanged = true;
|
|
204
|
+
lines.push(`${d.id} · ${cl.id} ✎ changed — audio will regenerate`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const sl of s.lines)
|
|
208
|
+
if (!d.lines.some((cl) => cl.id === sl.id)) {
|
|
209
|
+
removedLines++;
|
|
210
|
+
convChanged = true;
|
|
211
|
+
lines.push(`${d.id} · ${sl.id} ✕ removed`);
|
|
212
|
+
}
|
|
213
|
+
if (convChanged)
|
|
214
|
+
pushConvos.push(d.id);
|
|
215
|
+
}
|
|
216
|
+
const orphaned = conversations.filter((c) => !codeIds.has(c.id) && c.origin === "code").map((c) => c.id);
|
|
217
|
+
for (const id of orphaned)
|
|
218
|
+
lines.push(`${id} 🗑 not in code — will be pruned`);
|
|
219
|
+
const regen = newLines + changedLines;
|
|
220
|
+
const lossy = changedLines > 0 || removedLines > 0 || orphaned.length > 0;
|
|
221
|
+
const state = pushConvos.length === 0 && orphaned.length === 0 ? "insync" : lossy ? "lossy" : "push";
|
|
222
|
+
return { pushConvos, newLines, changedLines, removedLines, orphaned, regen, state, detail: lines.join("\n") };
|
|
223
|
+
}
|
|
224
|
+
async function runSync() {
|
|
225
|
+
const d = computeDiff();
|
|
226
|
+
if (d.state === "insync")
|
|
227
|
+
return;
|
|
228
|
+
if (d.state === "lossy") {
|
|
229
|
+
const parts = [
|
|
230
|
+
d.pushConvos.length ? `push ${d.pushConvos.length} conversation(s)` : "",
|
|
231
|
+
d.orphaned.length ? `PRUNE (delete): ${d.orphaned.join(", ")}` : "",
|
|
232
|
+
d.regen ? `${d.regen} line(s) will need regenerating` : "",
|
|
233
|
+
].filter(Boolean);
|
|
234
|
+
if (!confirm(`Sync will:\n· ${parts.join("\n· ")}\n\nProceed?`))
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (dirty && !confirm("Discard unsaved editor changes and sync from code?"))
|
|
238
|
+
return;
|
|
239
|
+
await syncConversations(session, { prune: true });
|
|
240
|
+
const docs = await session.listContent(CONVERSATIONS_COLLECTION).catch(() => []);
|
|
241
|
+
conversations = docs.map((x) => x.data).filter((c) => c && Array.isArray(c.lines));
|
|
242
|
+
if (selectedId && !defById(selectedId) && !storedById(selectedId))
|
|
243
|
+
selectedId = null;
|
|
244
|
+
if (selectedId)
|
|
245
|
+
working = buildWorking(selectedId);
|
|
246
|
+
dirty = false;
|
|
247
|
+
renderSide();
|
|
248
|
+
void renderMain();
|
|
249
|
+
}
|
|
250
|
+
function renderSyncBar() {
|
|
251
|
+
if (!codeAware)
|
|
252
|
+
return null;
|
|
253
|
+
const d = computeDiff();
|
|
254
|
+
const box = document.createElement("div");
|
|
255
|
+
box.className = "rydr-vo-sync " + d.state;
|
|
256
|
+
const chip = document.createElement("div");
|
|
257
|
+
chip.className = "chip";
|
|
258
|
+
chip.textContent =
|
|
259
|
+
d.state === "insync" ? "✓ In sync"
|
|
260
|
+
: d.state === "push" ? `⬆ Sync — ${d.pushConvos.length} to push`
|
|
261
|
+
: `⚠ Sync — ${d.pushConvos.length} push · ${d.orphaned.length} prune · ${d.regen} regen`;
|
|
262
|
+
box.appendChild(chip);
|
|
263
|
+
if (d.detail) {
|
|
264
|
+
const det = document.createElement("details");
|
|
265
|
+
const sum = document.createElement("summary");
|
|
266
|
+
sum.textContent = "details";
|
|
267
|
+
const pre = document.createElement("pre");
|
|
268
|
+
pre.textContent = d.detail;
|
|
269
|
+
det.append(sum, pre);
|
|
270
|
+
box.appendChild(det);
|
|
271
|
+
}
|
|
272
|
+
const btn = document.createElement("button");
|
|
273
|
+
btn.className = d.state === "lossy" ? "primary" : "";
|
|
274
|
+
btn.textContent = "Sync from code";
|
|
275
|
+
btn.disabled = d.state === "insync";
|
|
276
|
+
btn.onclick = () => { btn.disabled = true; void runSync().finally(() => { btn.disabled = false; }); };
|
|
277
|
+
box.appendChild(btn);
|
|
278
|
+
return box;
|
|
279
|
+
}
|
|
280
|
+
function selectConversation(id) {
|
|
281
|
+
selectedId = id;
|
|
282
|
+
working = buildWorking(id);
|
|
283
|
+
dirty = false;
|
|
284
|
+
renderSide();
|
|
285
|
+
void renderMain();
|
|
286
|
+
}
|
|
287
|
+
function newConversation() {
|
|
288
|
+
const id = uid();
|
|
289
|
+
working = { id, label: "New conversation", lines: [] }; // origin unset → editor-authored, never pruned
|
|
290
|
+
conversations.push(JSON.parse(JSON.stringify(working)));
|
|
291
|
+
selectedId = id;
|
|
292
|
+
dirty = true;
|
|
293
|
+
renderSide();
|
|
294
|
+
void renderMain();
|
|
295
|
+
}
|
|
296
|
+
function setView(v) {
|
|
297
|
+
view = v;
|
|
298
|
+
renderSide();
|
|
299
|
+
void renderMain();
|
|
300
|
+
}
|
|
301
|
+
function renderSide() {
|
|
302
|
+
side.textContent = "";
|
|
303
|
+
const bar = renderSyncBar();
|
|
304
|
+
if (bar)
|
|
305
|
+
side.appendChild(bar);
|
|
306
|
+
const tabs = document.createElement("div");
|
|
307
|
+
tabs.className = "rydr-vo-tabs";
|
|
308
|
+
for (const [v, label] of [["conversations", "Conversations"], ["characters", "Characters"]]) {
|
|
309
|
+
const t = document.createElement("div");
|
|
310
|
+
t.className = "rydr-vo-tab" + (view === v ? " sel" : "");
|
|
311
|
+
t.textContent = label;
|
|
312
|
+
t.onclick = () => setView(v);
|
|
313
|
+
tabs.appendChild(t);
|
|
314
|
+
}
|
|
315
|
+
side.appendChild(tabs);
|
|
316
|
+
if (view !== "conversations")
|
|
317
|
+
return;
|
|
318
|
+
const h = document.createElement("h2");
|
|
319
|
+
h.textContent = "Conversations";
|
|
320
|
+
side.appendChild(h);
|
|
321
|
+
for (const id of allConvIds()) {
|
|
322
|
+
const label = defById(id)?.id ?? storedById(id)?.label ?? id; // defs have no label; show id
|
|
323
|
+
const lineCount = (defById(id)?.lines ?? storedById(id)?.lines ?? []).length;
|
|
324
|
+
const tag = isCode(id) ? "code" : "editor";
|
|
325
|
+
const el = document.createElement("div");
|
|
326
|
+
el.className = "rydr-vo-conv" + (id === selectedId ? " sel" : "");
|
|
327
|
+
el.innerHTML = `${escapeHtml(storedById(id)?.label ?? label)} <span class="rydr-vo-tag">${tag}</span><small>${lineCount} line${lineCount === 1 ? "" : "s"}</small>`;
|
|
328
|
+
el.onclick = () => { if (dirty && !confirm("Discard unsaved changes?"))
|
|
329
|
+
return; selectConversation(id); };
|
|
330
|
+
side.appendChild(el);
|
|
331
|
+
}
|
|
332
|
+
const add = document.createElement("button");
|
|
333
|
+
add.className = "ghost";
|
|
334
|
+
add.textContent = "+ New conversation";
|
|
335
|
+
add.style.marginTop = "8px";
|
|
336
|
+
add.onclick = () => { if (dirty && !confirm("Discard unsaved changes?"))
|
|
337
|
+
return; newConversation(); };
|
|
338
|
+
side.appendChild(add);
|
|
339
|
+
}
|
|
340
|
+
async function renderMain() {
|
|
341
|
+
main.textContent = "";
|
|
342
|
+
if (view === "characters")
|
|
343
|
+
return renderCharacters();
|
|
344
|
+
return renderConversations();
|
|
345
|
+
}
|
|
346
|
+
async function renderConversations() {
|
|
347
|
+
if (!working) {
|
|
348
|
+
const empty = document.createElement("div");
|
|
349
|
+
empty.className = "rydr-vo-empty";
|
|
350
|
+
empty.textContent = codeAware ? "Select a conversation (code-defined ones are listed with a “code” tag)." : "Select a conversation, or create one.";
|
|
351
|
+
main.appendChild(empty);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const code = isCode(working.id);
|
|
355
|
+
const head = document.createElement("div");
|
|
356
|
+
head.className = "rydr-vo-head";
|
|
357
|
+
const title = document.createElement("input");
|
|
358
|
+
title.className = "title";
|
|
359
|
+
title.value = working.label ?? working.id;
|
|
360
|
+
title.readOnly = code; // code owns structure/label
|
|
361
|
+
if (!code)
|
|
362
|
+
title.oninput = () => { working.label = title.value; dirty = true; };
|
|
363
|
+
head.appendChild(title);
|
|
364
|
+
if (!code) {
|
|
365
|
+
const save = document.createElement("button");
|
|
366
|
+
save.className = "primary";
|
|
367
|
+
save.textContent = "Save";
|
|
368
|
+
save.onclick = () => { save.disabled = true; void persist().then(renderSide).finally(() => { save.disabled = false; void renderMain(); }); };
|
|
369
|
+
head.appendChild(save);
|
|
370
|
+
}
|
|
371
|
+
const genMissing = document.createElement("button");
|
|
372
|
+
genMissing.textContent = "Generate missing";
|
|
373
|
+
genMissing.onclick = () => void runBatch(["missing", "stale"], genMissing);
|
|
374
|
+
head.appendChild(genMissing);
|
|
375
|
+
if (!code) {
|
|
376
|
+
const del = document.createElement("button");
|
|
377
|
+
del.className = "ghost";
|
|
378
|
+
del.textContent = "Delete";
|
|
379
|
+
del.onclick = () => {
|
|
380
|
+
if (!confirm("Delete this conversation?"))
|
|
381
|
+
return;
|
|
382
|
+
void session.deleteContent(CONVERSATIONS_COLLECTION, working.id).then(() => {
|
|
383
|
+
conversations = conversations.filter((c) => c.id !== working.id);
|
|
384
|
+
selectedId = null;
|
|
385
|
+
working = null;
|
|
386
|
+
dirty = false;
|
|
387
|
+
renderSide();
|
|
388
|
+
void renderMain();
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
head.appendChild(del);
|
|
392
|
+
}
|
|
393
|
+
main.appendChild(head);
|
|
394
|
+
const note = document.createElement("div");
|
|
395
|
+
note.className = "rydr-vo-note";
|
|
396
|
+
note.textContent = code
|
|
397
|
+
? "From code — text + per-line style/speed are read-only (edit in code, then Sync). Cast voices in the Characters tab and generate audio here."
|
|
398
|
+
: "Editor-authored — for ad-hoc testing. Fully editable; Sync never prunes it.";
|
|
399
|
+
main.appendChild(note);
|
|
400
|
+
for (let i = 0; i < working.lines.length; i++) {
|
|
401
|
+
const l = working.lines[i];
|
|
402
|
+
if (l)
|
|
403
|
+
main.appendChild(await renderRow(l, i, code));
|
|
404
|
+
}
|
|
405
|
+
if (!code) {
|
|
406
|
+
const addLine = document.createElement("button");
|
|
407
|
+
addLine.className = "ghost";
|
|
408
|
+
addLine.textContent = "+ Add line";
|
|
409
|
+
addLine.onclick = () => {
|
|
410
|
+
const first = characters[0];
|
|
411
|
+
working.lines.push({ id: `${working.id}:${uid()}`, characterId: first?.id ?? "", text: "", lang: "fr" });
|
|
412
|
+
dirty = true;
|
|
413
|
+
void renderMain();
|
|
414
|
+
};
|
|
415
|
+
main.appendChild(addLine);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
async function renderRow(l, index, readOnly) {
|
|
419
|
+
const row = document.createElement("div");
|
|
420
|
+
row.className = "rydr-vo-row";
|
|
421
|
+
// Left: speaker
|
|
422
|
+
const left = document.createElement("div");
|
|
423
|
+
const sel = document.createElement("select");
|
|
424
|
+
// Ensure the current speaker is selectable even if it's a casting key not in the catalog.
|
|
425
|
+
const ids = new Set(characters.map((c) => c.id));
|
|
426
|
+
if (l.characterId && !ids.has(l.characterId)) {
|
|
427
|
+
const o = document.createElement("option");
|
|
428
|
+
o.value = l.characterId;
|
|
429
|
+
o.textContent = l.characterId;
|
|
430
|
+
o.selected = true;
|
|
431
|
+
sel.appendChild(o);
|
|
432
|
+
}
|
|
433
|
+
for (const c of characters) {
|
|
434
|
+
const o = document.createElement("option");
|
|
435
|
+
o.value = c.id;
|
|
436
|
+
const v = castOf(c.id)?.voice ?? c.voice;
|
|
437
|
+
o.textContent = v ? `${c.label} (${v})` : c.label;
|
|
438
|
+
if (c.id === l.characterId)
|
|
439
|
+
o.selected = true;
|
|
440
|
+
sel.appendChild(o);
|
|
441
|
+
}
|
|
442
|
+
sel.disabled = readOnly;
|
|
443
|
+
if (!readOnly)
|
|
444
|
+
sel.onchange = () => { l.characterId = sel.value; dirty = true; void renderMain(); };
|
|
445
|
+
left.appendChild(sel);
|
|
446
|
+
row.appendChild(left);
|
|
447
|
+
// Middle: text + (style, speed)
|
|
448
|
+
const mid = document.createElement("div");
|
|
449
|
+
const ta = document.createElement("textarea");
|
|
450
|
+
ta.value = l.text;
|
|
451
|
+
ta.placeholder = "Texte en français…";
|
|
452
|
+
ta.disabled = readOnly;
|
|
453
|
+
if (!readOnly)
|
|
454
|
+
ta.oninput = () => { l.text = ta.value; dirty = true; };
|
|
455
|
+
mid.appendChild(ta);
|
|
456
|
+
const subrow = document.createElement("div");
|
|
457
|
+
subrow.className = "rydr-vo-subrow";
|
|
458
|
+
const style = document.createElement("input");
|
|
459
|
+
style.className = "style";
|
|
460
|
+
style.placeholder = "per-line style (English, merged after character context)";
|
|
461
|
+
style.value = l.style ?? "";
|
|
462
|
+
style.disabled = readOnly;
|
|
463
|
+
if (!readOnly)
|
|
464
|
+
style.onchange = () => { l.style = style.value.trim() || undefined; dirty = true; void renderMain(); };
|
|
465
|
+
const speed = document.createElement("input");
|
|
466
|
+
speed.className = "speed";
|
|
467
|
+
speed.type = "number";
|
|
468
|
+
speed.step = "0.1";
|
|
469
|
+
speed.min = "0.25";
|
|
470
|
+
speed.max = "4";
|
|
471
|
+
speed.placeholder = `${effSpeed(l)}×`;
|
|
472
|
+
speed.value = l.speed != null ? String(l.speed) : "";
|
|
473
|
+
speed.title = "Per-line speaking-rate multiplier (blank = character/default)";
|
|
474
|
+
speed.disabled = readOnly;
|
|
475
|
+
if (!readOnly)
|
|
476
|
+
speed.onchange = () => { const n = parseFloat(speed.value); l.speed = Number.isFinite(n) ? n : undefined; dirty = true; void renderMain(); };
|
|
477
|
+
subrow.append(style, speed);
|
|
478
|
+
mid.appendChild(subrow);
|
|
479
|
+
row.appendChild(mid);
|
|
480
|
+
// Right: status + actions (Generate/Preview always available, even for code-defined lines)
|
|
481
|
+
const actions = document.createElement("div");
|
|
482
|
+
actions.className = "rydr-vo-actions";
|
|
483
|
+
const status = await statusOf(l);
|
|
484
|
+
const badge = document.createElement("span");
|
|
485
|
+
badge.className = "rydr-vo-badge " + status;
|
|
486
|
+
badge.textContent = { ok: "✓ generated", missing: "○ missing", stale: "⚠ stale", novoice: "no voice" }[status];
|
|
487
|
+
actions.appendChild(badge);
|
|
488
|
+
const play = document.createElement("button");
|
|
489
|
+
play.textContent = "▶ Preview";
|
|
490
|
+
play.disabled = !l.audioUrl;
|
|
491
|
+
play.onclick = () => l.audioUrl && preview(l.audioUrl);
|
|
492
|
+
actions.appendChild(play);
|
|
493
|
+
const gen = document.createElement("button");
|
|
494
|
+
gen.textContent = status === "ok" ? "↻ Regenerate" : "Generate";
|
|
495
|
+
gen.disabled = status === "novoice" || !l.text.trim();
|
|
496
|
+
gen.onclick = () => {
|
|
497
|
+
gen.disabled = true;
|
|
498
|
+
gen.textContent = "…";
|
|
499
|
+
void generateLine(l).then(() => persist()).then(() => renderSide()).catch((e) => alert(String(e))).finally(() => void renderMain());
|
|
500
|
+
};
|
|
501
|
+
actions.appendChild(gen);
|
|
502
|
+
if (!readOnly) {
|
|
503
|
+
const rm = document.createElement("button");
|
|
504
|
+
rm.className = "ghost";
|
|
505
|
+
rm.textContent = "Remove";
|
|
506
|
+
rm.onclick = () => { working.lines.splice(index, 1); dirty = true; void renderMain(); };
|
|
507
|
+
actions.appendChild(rm);
|
|
508
|
+
}
|
|
509
|
+
row.appendChild(actions);
|
|
510
|
+
return row;
|
|
511
|
+
}
|
|
512
|
+
function renderCharacters() {
|
|
513
|
+
const head = document.createElement("div");
|
|
514
|
+
head.className = "rydr-vo-head";
|
|
515
|
+
const h = document.createElement("input");
|
|
516
|
+
h.className = "title";
|
|
517
|
+
h.value = "Character voices";
|
|
518
|
+
h.readOnly = true;
|
|
519
|
+
h.style.border = "none";
|
|
520
|
+
head.appendChild(h);
|
|
521
|
+
main.appendChild(head);
|
|
522
|
+
const note = document.createElement("div");
|
|
523
|
+
note.className = "rydr-vo-note";
|
|
524
|
+
note.textContent = "Assign each speaker a voice + a persona context (in ENGLISH — a directorial note, e.g. \"Wise old man, speaks slowly, warm tone\") and a default speed. Per-game; merged into every line the character speaks. Saved on change.";
|
|
525
|
+
main.appendChild(note);
|
|
526
|
+
// Speakers = every speaker across code definitions ∪ stored conversations (so code speakers show
|
|
527
|
+
// before any Sync).
|
|
528
|
+
const ids = new Set();
|
|
529
|
+
for (const d of definitions)
|
|
530
|
+
for (const l of d.lines)
|
|
531
|
+
if (l.characterId)
|
|
532
|
+
ids.add(l.characterId);
|
|
533
|
+
for (const c of conversations)
|
|
534
|
+
for (const l of c.lines)
|
|
535
|
+
if (l.characterId)
|
|
536
|
+
ids.add(l.characterId);
|
|
537
|
+
if (ids.size === 0) {
|
|
538
|
+
const empty = document.createElement("div");
|
|
539
|
+
empty.className = "rydr-vo-empty";
|
|
540
|
+
empty.textContent = "No speakers yet — add lines in a conversation (or sync from code) first.";
|
|
541
|
+
main.appendChild(empty);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const list = document.createElement("div");
|
|
545
|
+
list.className = "rydr-vo-cast";
|
|
546
|
+
for (const id of ids)
|
|
547
|
+
list.appendChild(renderCastRow(id));
|
|
548
|
+
main.appendChild(list);
|
|
549
|
+
}
|
|
550
|
+
function renderCastRow(id) {
|
|
551
|
+
const entry = cast.get(id) ?? {};
|
|
552
|
+
const row = document.createElement("div");
|
|
553
|
+
row.className = "rydr-vo-castrow";
|
|
554
|
+
const label = document.createElement("div");
|
|
555
|
+
label.className = "clabel";
|
|
556
|
+
label.textContent = charById(id)?.label ?? id;
|
|
557
|
+
row.appendChild(label);
|
|
558
|
+
const voiceSel = document.createElement("select");
|
|
559
|
+
const none = document.createElement("option");
|
|
560
|
+
none.value = "";
|
|
561
|
+
none.textContent = "— no voice —";
|
|
562
|
+
voiceSel.appendChild(none);
|
|
563
|
+
for (const v of GEMINI_VOICES) {
|
|
564
|
+
const o = document.createElement("option");
|
|
565
|
+
o.value = v.id;
|
|
566
|
+
o.textContent = v.label;
|
|
567
|
+
if (entry.voice === v.id)
|
|
568
|
+
o.selected = true;
|
|
569
|
+
voiceSel.appendChild(o);
|
|
570
|
+
}
|
|
571
|
+
voiceSel.onchange = () => { const e = cast.get(id) ?? {}; e.voice = voiceSel.value || undefined; cast.set(id, e); void saveCast(id).then(renderSide); };
|
|
572
|
+
row.appendChild(voiceSel);
|
|
573
|
+
const context = document.createElement("textarea");
|
|
574
|
+
context.placeholder = "context / persona in ENGLISH — e.g. Wise old man, speaks slowly, warm tone";
|
|
575
|
+
context.value = entry.context ?? "";
|
|
576
|
+
context.onchange = () => { const e = cast.get(id) ?? {}; e.context = context.value.trim() || undefined; cast.set(id, e); void saveCast(id); };
|
|
577
|
+
row.appendChild(context);
|
|
578
|
+
const speed = document.createElement("input");
|
|
579
|
+
speed.type = "number";
|
|
580
|
+
speed.step = "0.1";
|
|
581
|
+
speed.min = "0.25";
|
|
582
|
+
speed.max = "4";
|
|
583
|
+
speed.placeholder = `${DEFAULT_VOICEOVER_SPEED}×`;
|
|
584
|
+
speed.value = entry.speed != null ? String(entry.speed) : "";
|
|
585
|
+
speed.title = "Default speaking-rate multiplier for this character";
|
|
586
|
+
speed.onchange = () => { const e = cast.get(id) ?? {}; const n = parseFloat(speed.value); e.speed = Number.isFinite(n) ? n : undefined; cast.set(id, e); void saveCast(id); };
|
|
587
|
+
row.appendChild(speed);
|
|
588
|
+
return row;
|
|
589
|
+
}
|
|
590
|
+
async function runBatch(want, btn) {
|
|
591
|
+
if (!working)
|
|
592
|
+
return;
|
|
593
|
+
btn.disabled = true;
|
|
594
|
+
const label = btn.textContent;
|
|
595
|
+
try {
|
|
596
|
+
for (const l of working.lines) {
|
|
597
|
+
const st = await statusOf(l);
|
|
598
|
+
if (!want.includes(st) || !l.text.trim() || !effVoice(l))
|
|
599
|
+
continue;
|
|
600
|
+
btn.textContent = "Generating…";
|
|
601
|
+
await generateLine(l).catch(() => { });
|
|
602
|
+
}
|
|
603
|
+
await persist();
|
|
604
|
+
renderSide();
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
btn.textContent = label;
|
|
608
|
+
btn.disabled = false;
|
|
609
|
+
void renderMain();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Initial load
|
|
613
|
+
void (async () => {
|
|
614
|
+
const [chars, convDocs, castDocs] = await Promise.all([
|
|
615
|
+
session.listCharacters().catch(() => []),
|
|
616
|
+
session.listContent(CONVERSATIONS_COLLECTION).catch(() => []),
|
|
617
|
+
session.listContent(VOICE_CAST_COLLECTION).catch(() => []),
|
|
618
|
+
]);
|
|
619
|
+
characters = chars;
|
|
620
|
+
conversations = convDocs.map((d) => d.data).filter((c) => c && Array.isArray(c.lines));
|
|
621
|
+
for (const d of castDocs)
|
|
622
|
+
if (d.data)
|
|
623
|
+
cast.set(d.id, d.data);
|
|
624
|
+
renderSide();
|
|
625
|
+
const first = allConvIds()[0];
|
|
626
|
+
if (first)
|
|
627
|
+
selectConversation(first);
|
|
628
|
+
else
|
|
629
|
+
void renderMain();
|
|
630
|
+
})();
|
|
631
|
+
return {
|
|
632
|
+
dispose() {
|
|
633
|
+
previewAudio?.pause();
|
|
634
|
+
wrap.remove();
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function escapeHtml(s) {
|
|
639
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
640
|
+
}
|
|
641
|
+
//# sourceMappingURL=index.js.map
|