ACloudCenter Claude Sonnet 5 commited on
Commit
c4cec2e
·
1 Parent(s): 2949f1d

Redesign the composer as a single input shell, not loose siblings

Browse files

The duration select and import link were separate elements next to the
textarea — the select's focus ring read as a validation error (thick
accent-colored border, mismatched height vs. the button), and "or paste
/ upload a script" was a barely-visible dotted-underline link.

Replaces it with the chat-composer pattern (ChatGPT/Claude.ai): one
bordered card containing the textarea, with a footer toolbar strip for
secondary controls — a borderless pill-style duration select, an actual
"Import script" button with an icon, and the primary CTA. Reads as a
single obvious input at a glance instead of three competing controls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. static/app.js +112 -7
  2. static/index.html +21 -10
  3. static/styles.css +103 -40
static/app.js CHANGED
@@ -29,6 +29,7 @@ const state = {
29
  numSpeakers: 2,
30
  voices: [],
31
  voiceSelections: [null, null, null, null],
 
32
  models: [],
33
  examples: [],
34
  parodyLines: [],
@@ -41,6 +42,7 @@ const el = {};
41
  [
42
  "runtimeStatus", "runtimeLabel", "aboutBtn", "aboutDialog", "closeAboutBtn",
43
  "modelSelect", "speakerStepper", "voiceRows", "cfgScale", "cfgScaleValue",
 
44
  "scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
45
  "scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
46
  "generateBarMeta", "generateBtn",
@@ -120,6 +122,21 @@ state.previewAudio.addEventListener("ended", () => {
120
  });
121
 
122
  /* ---------------- Sidebar: model / speakers / voices ---------------- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  function renderSidebar() {
124
  el.modelSelect.innerHTML = state.models.map((m) => `<option value="${m}">${m}</option>`).join("");
125
 
@@ -137,12 +154,10 @@ function renderSidebar() {
137
  dot.style.background = `var(--speaker-${i + 1})`;
138
 
139
  const select = document.createElement("select");
140
- select.innerHTML = state.voices.map((v) => `<option value="${v.name}">${v.name} (${v.gender})</option>`).join("");
 
 
141
  if (state.voiceSelections[i]) select.value = state.voiceSelections[i];
142
- select.addEventListener("change", () => {
143
- state.voiceSelections[i] = select.value;
144
- renderTurns();
145
- });
146
 
147
  const playBtn = document.createElement("button");
148
  playBtn.type = "button";
@@ -150,11 +165,62 @@ function renderSidebar() {
150
  playBtn.textContent = "▶";
151
  playBtn.title = "Preview voice";
152
  playBtn.addEventListener("click", () => playVoicePreview(select.value, playBtn));
153
- select.addEventListener("change", () => { playBtn.textContent = "▶"; });
 
 
 
 
 
 
 
154
 
155
  row.append(dot, select, playBtn);
156
  el.voiceRows.append(row);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  }
 
 
158
  }
159
 
160
  el.speakerStepper.querySelectorAll("button").forEach((btn) => {
@@ -171,6 +237,7 @@ el.cfgScale.addEventListener("input", () => {
171
  /* ---------------- Turn editor ---------------- */
172
  function speakerChoiceLabel(i) {
173
  const sel = state.voiceSelections[i];
 
174
  return sel ? `Speaker ${i + 1} · ${sel} (${genderOf(sel)})` : `Speaker ${i + 1}`;
175
  }
176
 
@@ -413,6 +480,15 @@ el.logToggleBtn.addEventListener("click", () => {
413
  });
414
 
415
  /* ---------------- Generate ---------------- */
 
 
 
 
 
 
 
 
 
416
  el.generateBtn.addEventListener("click", async () => {
417
  const script = state.turns.map((t) => (t.text || "").trim()).filter(Boolean).join(" ");
418
  if (!script) {
@@ -420,6 +496,17 @@ el.generateBtn.addEventListener("click", async () => {
420
  return;
421
  }
422
 
 
 
 
 
 
 
 
 
 
 
 
423
  el.generateBtn.disabled = true;
424
  el.generateBtn.textContent = "Generating...";
425
  el.resultBlock.classList.remove("visible");
@@ -431,12 +518,30 @@ el.generateBtn.addEventListener("click", async () => {
431
  const started = performance.now();
432
  setStatus("connecting", nextParodyLine() || "Provisioning GPU resources...");
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  const payload = {
435
  model: el.modelSelect.value,
436
  num_speakers: state.numSpeakers,
437
  turns: state.turns,
438
- speakers: state.voiceSelections,
439
  cfg_scale: Number(el.cfgScale.value),
 
 
440
  };
441
 
442
  try {
 
29
  numSpeakers: 2,
30
  voices: [],
31
  voiceSelections: [null, null, null, null],
32
+ customVoiceFiles: [null, null, null, null],
33
  models: [],
34
  examples: [],
35
  parodyLines: [],
 
42
  [
43
  "runtimeStatus", "runtimeLabel", "aboutBtn", "aboutDialog", "closeAboutBtn",
44
  "modelSelect", "speakerStepper", "voiceRows", "cfgScale", "cfgScaleValue",
45
+ "voiceConsentRow", "voiceConsentCheckbox",
46
  "scriptPrompt", "durationSelect", "generateScriptBtn", "examplePills", "openImportBtn", "scriptGenStatus",
47
  "scriptTitle", "scriptDuration", "turnsList", "addTurnBtn",
48
  "generateBarMeta", "generateBtn",
 
122
  });
123
 
124
  /* ---------------- Sidebar: model / speakers / voices ---------------- */
125
+ const CUSTOM_VOICE_VALUE = "__custom__";
126
+ const MAX_CUSTOM_AUDIO_BYTES = 15 * 1024 * 1024;
127
+
128
+ function isCustomVoice(i) {
129
+ return state.voiceSelections[i] === CUSTOM_VOICE_VALUE;
130
+ }
131
+
132
+ function anyCustomVoiceActive() {
133
+ return Array.from({ length: state.numSpeakers }, (_, i) => i).some(isCustomVoice);
134
+ }
135
+
136
+ function updateVoiceConsentVisibility() {
137
+ el.voiceConsentRow.hidden = !anyCustomVoiceActive();
138
+ }
139
+
140
  function renderSidebar() {
141
  el.modelSelect.innerHTML = state.models.map((m) => `<option value="${m}">${m}</option>`).join("");
142
 
 
154
  dot.style.background = `var(--speaker-${i + 1})`;
155
 
156
  const select = document.createElement("select");
157
+ select.innerHTML =
158
+ `<option value="${CUSTOM_VOICE_VALUE}">🎙️ Clone a voice…</option>` +
159
+ state.voices.map((v) => `<option value="${v.name}">${v.name} (${v.gender})</option>`).join("");
160
  if (state.voiceSelections[i]) select.value = state.voiceSelections[i];
 
 
 
 
161
 
162
  const playBtn = document.createElement("button");
163
  playBtn.type = "button";
 
165
  playBtn.textContent = "▶";
166
  playBtn.title = "Preview voice";
167
  playBtn.addEventListener("click", () => playVoicePreview(select.value, playBtn));
168
+
169
+ select.addEventListener("change", () => {
170
+ state.voiceSelections[i] = select.value;
171
+ if (select.value !== CUSTOM_VOICE_VALUE) state.customVoiceFiles[i] = null;
172
+ playBtn.textContent = "▶";
173
+ renderSidebar();
174
+ renderTurns();
175
+ });
176
 
177
  row.append(dot, select, playBtn);
178
  el.voiceRows.append(row);
179
+
180
+ if (isCustomVoice(i)) {
181
+ playBtn.hidden = true;
182
+ const uploadRow = document.createElement("div");
183
+ uploadRow.className = "custom-voice-row";
184
+
185
+ const fileLabel = document.createElement("label");
186
+ fileLabel.className = "btn btn-sm upload-mini-btn";
187
+ fileLabel.textContent = state.customVoiceFiles[i] ? state.customVoiceFiles[i].name : "Choose audio file…";
188
+ const fileInput = document.createElement("input");
189
+ fileInput.type = "file";
190
+ fileInput.accept = "audio/*";
191
+ fileInput.hidden = true;
192
+ fileInput.addEventListener("change", () => {
193
+ const file = fileInput.files[0];
194
+ if (!file) return;
195
+ if (file.size > MAX_CUSTOM_AUDIO_BYTES) {
196
+ alert(`That file is too large (max ${MAX_CUSTOM_AUDIO_BYTES / (1024 * 1024)} MB).`);
197
+ fileInput.value = "";
198
+ return;
199
+ }
200
+ state.customVoiceFiles[i] = file;
201
+ renderSidebar();
202
+ });
203
+ fileLabel.append(fileInput);
204
+ uploadRow.append(fileLabel);
205
+
206
+ if (state.customVoiceFiles[i]) {
207
+ const clearBtn = document.createElement("button");
208
+ clearBtn.type = "button";
209
+ clearBtn.className = "btn btn-sm btn-icon-only";
210
+ clearBtn.textContent = "✕";
211
+ clearBtn.title = "Remove file";
212
+ clearBtn.addEventListener("click", () => {
213
+ state.customVoiceFiles[i] = null;
214
+ renderSidebar();
215
+ });
216
+ uploadRow.append(clearBtn);
217
+ }
218
+
219
+ el.voiceRows.append(uploadRow);
220
+ }
221
  }
222
+
223
+ updateVoiceConsentVisibility();
224
  }
225
 
226
  el.speakerStepper.querySelectorAll("button").forEach((btn) => {
 
237
  /* ---------------- Turn editor ---------------- */
238
  function speakerChoiceLabel(i) {
239
  const sel = state.voiceSelections[i];
240
+ if (sel === CUSTOM_VOICE_VALUE) return `Speaker ${i + 1} · Custom voice`;
241
  return sel ? `Speaker ${i + 1} · ${sel} (${genderOf(sel)})` : `Speaker ${i + 1}`;
242
  }
243
 
 
480
  });
481
 
482
  /* ---------------- Generate ---------------- */
483
+ function fileToBase64(file) {
484
+ return new Promise((resolve, reject) => {
485
+ const reader = new FileReader();
486
+ reader.onload = () => resolve(String(reader.result).split(",")[1] || "");
487
+ reader.onerror = () => reject(new Error(`Could not read ${file.name}`));
488
+ reader.readAsDataURL(file);
489
+ });
490
+ }
491
+
492
  el.generateBtn.addEventListener("click", async () => {
493
  const script = state.turns.map((t) => (t.text || "").trim()).filter(Boolean).join(" ");
494
  if (!script) {
 
496
  return;
497
  }
498
 
499
+ for (let i = 0; i < state.numSpeakers; i += 1) {
500
+ if (isCustomVoice(i) && !state.customVoiceFiles[i]) {
501
+ alert(`Upload a voice clip for Speaker ${i + 1}, or pick a preset voice instead.`);
502
+ return;
503
+ }
504
+ }
505
+ if (anyCustomVoiceActive() && !el.voiceConsentCheckbox.checked) {
506
+ alert("Confirm you have the right to use each uploaded voice before generating.");
507
+ return;
508
+ }
509
+
510
  el.generateBtn.disabled = true;
511
  el.generateBtn.textContent = "Generating...";
512
  el.resultBlock.classList.remove("visible");
 
518
  const started = performance.now();
519
  setStatus("connecting", nextParodyLine() || "Provisioning GPU resources...");
520
 
521
+ let customAudio;
522
+ try {
523
+ customAudio = await Promise.all(
524
+ Array.from({ length: 4 }, (_, i) =>
525
+ i < state.numSpeakers && isCustomVoice(i) && state.customVoiceFiles[i]
526
+ ? fileToBase64(state.customVoiceFiles[i])
527
+ : Promise.resolve(null)
528
+ )
529
+ );
530
+ } catch (error) {
531
+ setStatus("error", error.message);
532
+ el.generateBtn.disabled = false;
533
+ el.generateBtn.textContent = "Generate Audio";
534
+ return;
535
+ }
536
+
537
  const payload = {
538
  model: el.modelSelect.value,
539
  num_speakers: state.numSpeakers,
540
  turns: state.turns,
541
+ speakers: state.voiceSelections.map((v) => (v === CUSTOM_VOICE_VALUE ? null : v)),
542
  cfg_scale: Number(el.cfgScale.value),
543
+ custom_audio: customAudio,
544
+ voice_consent: el.voiceConsentCheckbox.checked,
545
  };
546
 
547
  try {
static/index.html CHANGED
@@ -44,6 +44,12 @@
44
  <button type="button" data-count="4">4</button>
45
  </div>
46
  <div id="voiceRows"></div>
 
 
 
 
 
 
47
  </div>
48
 
49
  <div class="side-section">
@@ -64,19 +70,24 @@
64
  <main class="pane canvas">
65
  <div class="canvas-inner">
66
  <div class="composer">
67
- <div class="composer-row">
68
- <textarea
69
- id="scriptPrompt"
70
- rows="4"
71
- placeholder="Describe a scenario — a wizard and an orc debating battle strategy..."
72
- ></textarea>
73
- <select id="durationSelect" class="duration-select" aria-label="Target length"></select>
 
 
 
 
 
 
74
  <button id="generateScriptBtn" class="btn btn-ink" type="button">Write with AI</button>
75
  </div>
76
- <div class="example-chips" id="examplePills"></div>
77
- <button class="chip-import" id="openImportBtn" type="button">or paste / upload a script &rarr;</button>
78
- <div id="scriptGenStatus" class="transcript-meta" style="margin-top: 8px;"></div>
79
  </div>
 
 
80
 
81
  <div class="transcript-header">
82
  <h2 id="scriptTitle">Untitled conversation</h2>
 
44
  <button type="button" data-count="4">4</button>
45
  </div>
46
  <div id="voiceRows"></div>
47
+ <div id="voiceConsentRow" class="voice-consent-row" hidden>
48
+ <label>
49
+ <input type="checkbox" id="voiceConsentCheckbox" />
50
+ I have the right to use each uploaded voice and will disclose this audio as synthetic.
51
+ </label>
52
+ </div>
53
  </div>
54
 
55
  <div class="side-section">
 
70
  <main class="pane canvas">
71
  <div class="canvas-inner">
72
  <div class="composer">
73
+ <textarea
74
+ id="scriptPrompt"
75
+ class="composer-input"
76
+ rows="3"
77
+ placeholder="Describe a scenario — a wizard and an orc debating battle strategy..."
78
+ ></textarea>
79
+ <div class="composer-toolbar">
80
+ <div class="composer-toolbar-left">
81
+ <select id="durationSelect" class="toolbar-select" aria-label="Target length"></select>
82
+ <button class="toolbar-btn" id="openImportBtn" type="button">
83
+ <span aria-hidden="true">📋</span> Import script
84
+ </button>
85
+ </div>
86
  <button id="generateScriptBtn" class="btn btn-ink" type="button">Write with AI</button>
87
  </div>
 
 
 
88
  </div>
89
+ <div class="example-chips" id="examplePills"></div>
90
+ <div id="scriptGenStatus" class="transcript-meta" style="margin: 6px 0 4px;"></div>
91
 
92
  <div class="transcript-header">
93
  <h2 id="scriptTitle">Untitled conversation</h2>
static/styles.css CHANGED
@@ -199,6 +199,42 @@ select:focus { outline: none; border-color: var(--accent); }
199
  .voice-play:hover { border-color: var(--accent); color: var(--accent); }
200
  .voice-play.playing { background: var(--accent); border-color: var(--accent); color: #fff; }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  .slider-row { display: flex; align-items: center; gap: 10px; }
203
  input[type="range"] { flex: 1; accent-color: var(--accent); }
204
  .slider-value { font-size: 0.8rem; color: var(--ink-dim); min-width: 32px; text-align: right; }
@@ -216,51 +252,88 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
216
  .canvas { background: var(--cream); padding: 26px 32px 140px; position: relative; }
217
  .canvas-inner { max-width: 720px; margin: 0 auto; }
218
 
 
 
 
 
219
  .composer {
220
  background: var(--paper);
221
  border: 1px solid var(--border-strong);
222
  border-radius: var(--radius);
223
- padding: 16px 18px;
224
  box-shadow: var(--shadow);
225
- margin-bottom: 14px;
226
- }
227
- .composer-row { display: flex; gap: 10px; align-items: flex-end; }
228
- .duration-select {
229
- width: auto;
230
- flex-shrink: 0;
231
- background-color: var(--field);
232
- background-position: right 9px center;
233
- border: 1px solid var(--border-strong);
234
- border-radius: var(--radius-sm);
235
- padding: 10px 26px 10px 10px;
236
- font-size: 0.82rem;
237
- color: var(--ink-dim);
238
- align-self: stretch;
239
  }
240
- .duration-select:focus { outline: none; border-color: var(--accent); }
241
- .composer textarea {
242
- flex: 1;
243
- border: 1px solid var(--border-strong);
244
  resize: none;
245
- background: var(--field);
246
- box-shadow: inset 0 1px 3px rgba(38, 32, 25, 0.08);
247
- border-radius: var(--radius-sm);
248
- padding: 10px 12px;
249
- font-size: 1rem;
250
- line-height: 1.45;
251
  font-family: var(--font);
252
  color: var(--ink);
253
- min-height: 104px;
254
  max-height: 240px;
255
  cursor: text;
256
  }
257
- .composer textarea:focus {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  outline: none;
259
- border-color: var(--accent);
260
- background: var(--paper);
261
  box-shadow: 0 0 0 3px var(--accent-soft);
262
  }
263
- .composer textarea::placeholder { color: var(--ink-faint); font-style: italic; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
  .btn {
266
  font-family: var(--font-ui);
@@ -302,16 +375,6 @@ input[type="range"] { flex: 1; accent-color: var(--accent); }
302
  cursor: pointer;
303
  }
304
  .chip:hover { border-color: var(--accent); color: var(--accent); }
305
- .chip-import {
306
- font-size: 0.78rem;
307
- color: var(--ink-faint);
308
- background: none;
309
- border: none;
310
- cursor: pointer;
311
- text-decoration: underline;
312
- text-decoration-style: dotted;
313
- padding: 5px 4px;
314
- }
315
 
316
  /* ---------------- Transcript ---------------- */
317
  .transcript-header {
 
199
  .voice-play:hover { border-color: var(--accent); color: var(--accent); }
200
  .voice-play.playing { background: var(--accent); border-color: var(--accent); color: #fff; }
201
 
202
+ .custom-voice-row {
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 6px;
206
+ margin: -4px 0 8px 17px;
207
+ }
208
+ .upload-mini-btn {
209
+ font-size: 0.76rem;
210
+ padding: 6px 10px;
211
+ color: var(--ink-dim);
212
+ cursor: pointer;
213
+ max-width: 190px;
214
+ overflow: hidden;
215
+ text-overflow: ellipsis;
216
+ white-space: nowrap;
217
+ display: inline-block;
218
+ }
219
+ .upload-mini-btn:hover { border-color: var(--accent); color: var(--accent); }
220
+
221
+ .voice-consent-row {
222
+ border: 1px solid var(--speaker-3);
223
+ background: rgba(184, 134, 43, 0.08);
224
+ border-radius: var(--radius-sm);
225
+ padding: 10px 12px;
226
+ margin-top: 4px;
227
+ }
228
+ .voice-consent-row label {
229
+ display: flex;
230
+ align-items: flex-start;
231
+ gap: 8px;
232
+ font-size: 0.78rem;
233
+ color: var(--ink-dim);
234
+ cursor: pointer;
235
+ }
236
+ .voice-consent-row input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
237
+
238
  .slider-row { display: flex; align-items: center; gap: 10px; }
239
  input[type="range"] { flex: 1; accent-color: var(--accent); }
240
  .slider-value { font-size: 0.8rem; color: var(--ink-dim); min-width: 32px; text-align: right; }
 
252
  .canvas { background: var(--cream); padding: 26px 32px 140px; position: relative; }
253
  .canvas-inner { max-width: 720px; margin: 0 auto; }
254
 
255
+ /* ---- Composer: a single input "shell" (textarea + toolbar), the
256
+ chat-composer pattern from ChatGPT/Claude.ai — unmistakable as an
257
+ input at a glance, with secondary controls grouped in a footer strip
258
+ instead of competing with the textarea as loose sibling elements. ---- */
259
  .composer {
260
  background: var(--paper);
261
  border: 1px solid var(--border-strong);
262
  border-radius: var(--radius);
 
263
  box-shadow: var(--shadow);
264
+ overflow: hidden;
265
+ margin-bottom: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
266
  }
267
+ .composer-input {
268
+ display: block;
269
+ width: 100%;
270
+ border: none;
271
  resize: none;
272
+ background: transparent;
273
+ padding: 18px 20px 8px;
274
+ font-size: 1.05rem;
275
+ line-height: 1.5;
 
 
276
  font-family: var(--font);
277
  color: var(--ink);
278
+ min-height: 100px;
279
  max-height: 240px;
280
  cursor: text;
281
  }
282
+ .composer-input:focus { outline: none; }
283
+ .composer-input::placeholder { color: var(--ink-faint); font-style: italic; }
284
+
285
+ .composer-toolbar {
286
+ display: flex;
287
+ align-items: center;
288
+ justify-content: space-between;
289
+ gap: 10px;
290
+ padding: 9px 12px;
291
+ border-top: 1px solid var(--border);
292
+ background: var(--paper-soft);
293
+ }
294
+ .composer-toolbar-left { display: flex; align-items: center; gap: 2px; }
295
+
296
+ .toolbar-select {
297
+ width: auto;
298
+ background-color: transparent;
299
+ background-position: right 7px center;
300
+ background-size: 9px 6px;
301
+ border: 1px solid transparent;
302
+ border-radius: 999px;
303
+ color: var(--ink-dim);
304
+ font-size: 0.82rem;
305
+ font-weight: 600;
306
+ padding: 7px 24px 7px 11px;
307
+ }
308
+ .toolbar-select:hover { background-color: var(--paper); border-color: var(--border); }
309
+ .toolbar-select:focus {
310
  outline: none;
311
+ background-color: var(--paper);
312
+ border-color: var(--border-strong);
313
  box-shadow: 0 0 0 3px var(--accent-soft);
314
  }
315
+
316
+ .toolbar-btn {
317
+ display: inline-flex;
318
+ align-items: center;
319
+ gap: 6px;
320
+ border: 1px solid transparent;
321
+ background: transparent;
322
+ color: var(--ink-dim);
323
+ font-size: 0.82rem;
324
+ font-weight: 600;
325
+ padding: 7px 12px;
326
+ border-radius: 999px;
327
+ cursor: pointer;
328
+ white-space: nowrap;
329
+ }
330
+ .toolbar-btn:hover { background: var(--paper); border-color: var(--border); color: var(--accent); }
331
+
332
+ .composer-toolbar .btn-ink {
333
+ border-radius: 999px;
334
+ padding: 9px 18px;
335
+ font-size: 0.84rem;
336
+ }
337
 
338
  .btn {
339
  font-family: var(--font-ui);
 
375
  cursor: pointer;
376
  }
377
  .chip:hover { border-color: var(--accent); color: var(--accent); }
 
 
 
 
 
 
 
 
 
 
378
 
379
  /* ---------------- Transcript ---------------- */
380
  .transcript-header {