Captions: translated subtitles for the training videos, no re-render, no re-voicing

Manson asked whether the training videos could be in other languages. A real dub
means re-rendering every video per language: translated Romance-language speech runs
15-25% longer than English and these are slide videos with fixed beat timings, so
swapped audio drifts off what is on screen. That turns 15 files into 75 and makes
every future lesson edit a five-way job. Captions keep ONE video and add small text
tracks beside it, so editing a lesson re-captions that lesson only.

tools/captions.mjs: ffmpeg pulls the audio, ElevenLabs Scribe transcribes with word
timings (the same STT we already use to verify voiceovers), words are grouped into
SENTENCES, and only then translated. Translating cue-by-cue is why most auto-captions
read badly at cue boundaries. Translation goes through our own /api/public/translate,
so every phrase caches forever in translations.json and costs once across the site.
The STT response is cached on disk because it costs money; never pay for it twice.

Language choice is evidence, not instinct. The translation cache shows real member
demand: Italian and French far ahead, then Spanish, then GERMAN - which beats
Portuguese by more than double, the opposite of what we assumed. Proof of concept is
Italian on the 5-minute overview.

Captions are deliberately NOT on by default. An English reader does not want them
forced over the picture; someone who already switched the site to Italian almost
certainly does. public/vtt-lang.js shows the track matching their 🌐 choice and
leaves the player's CC button to do the rest.

Two bugs this caught in my own code, both found by reading the output:
- the line wrapper truncated each cue to two lines and SILENTLY DELETED the overflow,
  so "the whole plan fits in one sentence" shipped as "fits in" then "sentence". It
  now chunks by the real wrapped line count and never drops a word.
- a one or two word tail ("sentence." alone on screen) folds back into the previous
  cue.
The suite asserts all 780 transcript words survive into the English track.

Also: .vtt had no Content-Type mapping, so it served as octet-stream and browsers
silently ignore such a track. qa/captions-e2e.mjs (12 assertions) reads the parsed
cues back out of the player rather than trusting the markup, which is the only way to
catch that class of failure. profiles-unit 28 and gate-e2e 33 still green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 06:06:34 -05:00
parent 8bdc105a7a
commit b3385ca3f8
7 changed files with 3094 additions and 1935 deletions
@@ -0,0 +1,424 @@
WEBVTT
1
00:00:00.360 --> 00:00:02.060
Most team builds fail for one reason.
2
00:00:02.360 --> 00:00:04.800
Everybody recruits and nobody helps.
3
00:00:05.840 --> 00:00:07.380
The RM Circle flips that.
4
00:00:08.220 --> 00:00:13.140
It's a team-first build on the Polygon
blockchain where the whole plan fits in
5
00:00:13.180 --> 00:00:13.880
one sentence.
6
00:00:13.920 --> 00:00:18.860
Get your two, help your two get theirs,
and teach them to do the same.
7
00:00:19.160 --> 00:00:24.880
This video is the full picture, how the
structure works, how the money moves, and
8
00:00:24.920 --> 00:00:26.500
exactly what your job is.
9
00:00:27.080 --> 00:00:28.560
It's simpler than you think.
10
00:00:29.540 --> 00:00:30.780
Everyone enters the same way.
11
00:00:31.320 --> 00:00:34.540
One position on a smart contract placed in
a two-wide matrix.
12
00:00:35.080 --> 00:00:37.820
You need exactly two people who join with
your link.
13
00:00:38.380 --> 00:00:39.620
We call them your directs.
14
00:00:40.240 --> 00:00:42.920
And once you have two, your position is
qualified.
15
00:00:43.420 --> 00:00:45.020
That's the finish line for width.
16
00:00:45.820 --> 00:00:46.820
You never need a third.
17
00:00:47.000 --> 00:00:49.320
From there, the build goes down, not out.
18
00:00:50.120 --> 00:00:51.760
Your two each get their two.
19
00:00:51.820 --> 00:00:53.040
Those four get theirs.
20
00:00:53.780 --> 00:00:58.740
And the matrix fills two, four, eight,
sixteen with the whole team pushing the
21
00:00:58.800 --> 00:00:59.800
same wave.
22
00:01:00.340 --> 00:01:02.560
You get paid two ways, and they're
different.
23
00:01:03.060 --> 00:01:05.440
Knowing the difference saves you a lot of
confusion.
24
00:01:05.980 --> 00:01:07.720
Stream one, entry rewards.
25
00:01:08.320 --> 00:01:12.680
Every single person who joins on your link
pays you an entry reward delivered to your
26
00:01:12.760 --> 00:01:15.400
wallet in the same transaction they join
with.
27
00:01:15.960 --> 00:01:18.180
Not just the first two, everyone.
28
00:01:18.740 --> 00:01:20.820
Stream two, upgrade payments.
29
00:01:21.320 --> 00:01:22.880
The program has eight levels.
30
00:01:23.540 --> 00:01:27.720
As people below you buy levels, each
payment travels up the matrix and lands on
31
00:01:27.780 --> 00:01:30.300
one position, the one at the matching
depth.
32
00:01:30.960 --> 00:01:35.400
Someone one layer below you pays you when
they buy level two.
33
00:01:36.380 --> 00:01:40.080
Two layers down, level three, and so on,
eight layers deep.
34
00:01:40.320 --> 00:01:44.020
Each person below you pays you once at
exactly one level.
35
00:01:44.980 --> 00:01:47.840
Here's the rule that decides whether you
catch that money.
36
00:01:48.260 --> 00:01:53.720
To receive a level payment, your position
must be qualified, two directs, and
37
00:01:53.800 --> 00:01:55.260
already at the level being bought.
38
00:01:55.680 --> 00:01:57.720
If you're not, the payment doesn't wait.
39
00:01:58.300 --> 00:02:01.880
It passes to the next eligible position
above you permanently.
40
00:02:02.700 --> 00:02:03.700
That's not a punishment.
41
00:02:03.900 --> 00:02:06.280
It's the contract keeping every payment
moving.
42
00:02:07.120 --> 00:02:11.180
Your job is simply to stay one level ahead
of your team's deepest active layer.
43
00:02:12.180 --> 00:02:15.800
And your dashboard literally shows you
when the moment is coming.
44
00:02:16.700 --> 00:02:20.300
When someone's two slots are full and
another person joins their team, the
45
00:02:20.360 --> 00:02:24.860
contract places that new member in the
first open slot further down.
46
00:02:25.440 --> 00:02:26.580
That's spillover.
47
00:02:28.080 --> 00:02:32.280
Spillover fills your structure with people
you never recruited, and every one of them
48
00:02:32.340 --> 00:02:34.800
is future upgrade income for your
position.
49
00:02:35.280 --> 00:02:38.500
But hear this clearly, spillover does not
qualify you.
50
00:02:38.960 --> 00:02:42.980
Only people who join with your own link
count as your directs.
51
00:02:43.860 --> 00:02:45.780
Spillover is a gift for your depth.
52
00:02:46.600 --> 00:02:48.240
Your two is still your job.
53
00:02:49.140 --> 00:02:52.020
Once you're qualified, the team's
recommendation is simple.
54
00:02:52.860 --> 00:02:54.080
Shift your effort down.
55
00:02:54.900 --> 00:02:58.520
Help your two get their two instead of
stacking more directs on yourself.
56
00:02:59.540 --> 00:03:02.240
Your invite page even handles this
automatically.
57
00:03:02.700 --> 00:03:08.220
Once you hit two of two, it routes new
joins to the next person in your leg who
58
00:03:08.280 --> 00:03:08.940
needs them.
59
00:03:08.980 --> 00:03:13.320
And if someone still joins on your link
afterward, then that's a bonus, never a
60
00:03:13.360 --> 00:03:14.360
problem.
61
00:03:14.400 --> 00:03:18.700
You keep the full entry reward, and they
spill down into your leg as depth.
62
00:03:19.680 --> 00:03:20.780
There's no rule against it.
63
00:03:20.960 --> 00:03:22.560
There's just a smarter place to aim.
64
00:03:24.060 --> 00:03:26.820
This is where the RM Circle is different
from anything you've tried.
65
00:03:27.580 --> 00:03:29.260
Your dashboard is your co-pilot.
66
00:03:29.880 --> 00:03:34.400
It shows your team live, the payments
forming below you, exactly when to upgrade
67
00:03:34.440 --> 00:03:37.460
to catch them, and who on your team needs
help next.
68
00:03:38.400 --> 00:03:41.260
With a message system built in, verified
by your wallet.
69
00:03:41.780 --> 00:03:43.560
And every member has the same dashboard.
70
00:03:43.640 --> 00:03:47.840
So when you coach your two, don't just
tell them what to do.
71
00:03:48.220 --> 00:03:51.640
Open their dashboard with them and teach
them to run the same play for their two.
72
00:03:52.560 --> 00:03:53.880
You're not building a downline.
73
00:03:54.100 --> 00:03:55.100
You're training coaches.
74
00:03:55.100 --> 00:03:57.400
That's what builds depth that pays for
years.
75
00:03:58.320 --> 00:03:59.760
Getting in takes three steps.
76
00:04:00.560 --> 00:04:02.240
Watch the short trainings on the site.
77
00:04:02.920 --> 00:04:05.280
Set up a free wallet and fund it with
POEL.
78
00:04:06.160 --> 00:04:08.500
You can buy it with a debit card if you're
brand new.
79
00:04:09.120 --> 00:04:10.980
And join right on the site with one tap.
80
00:04:11.660 --> 00:04:14.900
You'll be placed on the team
automatically, and your dashboard is live
81
00:04:14.940 --> 00:04:16.779
the second your transaction confirms.
82
00:04:17.700 --> 00:04:23.180
Last thing, because we only do this
honestly, this is cryptocurrency on a
83
00:04:23.300 --> 00:04:25.820
smart contract with real risk.
84
00:04:26.460 --> 00:04:27.760
No income is guaranteed.
85
00:04:28.140 --> 00:04:32.220
Results come from team effort, and you
should only ever use money you can afford
86
00:04:32.260 --> 00:04:33.100
to lose.
87
00:04:33.140 --> 00:04:34.920
What is guaranteed is the math.
88
00:04:35.440 --> 00:04:36.680
The code can't be changed.
89
00:04:36.900 --> 00:04:38.440
No company holds the funds.
90
00:04:38.820 --> 00:04:41.480
And every payment ever made is public on
the blockchain.
91
00:04:42.140 --> 00:04:43.140
Get your two.
92
00:04:43.380 --> 00:04:44.760
Help your two get theirs.
93
00:04:45.200 --> 00:04:46.200
Teach them to teach.
94
00:04:46.680 --> 00:04:48.260
RM Circle dot team.
@@ -0,0 +1,445 @@
WEBVTT
1
00:00:00.360 --> 00:00:02.060
La maggior parte dei team build fallisce
per un motivo.
2
00:00:02.360 --> 00:00:04.800
Tutti reclutano e nessuno aiuta.
3
00:00:05.840 --> 00:00:07.380
RM Circle ribalta la situazione.
4
00:00:08.220 --> 00:00:13.140
È un team build incentrato sul team sulla
blockchain Polygon, dove l'intero piano
sta in
5
00:00:13.180 --> 00:00:13.880
una frase.
6
00:00:13.920 --> 00:00:18.860
Trova i tuoi due, aiuta i tuoi due a
trovare i loro, e insegna loro a fare lo
stesso.
7
00:00:19.160 --> 00:00:24.880
Questo video è il quadro completo: come
funziona la struttura, come si muove il
denaro e qual
8
00:00:24.920 --> 00:00:26.500
è esattamente il tuo compito.
9
00:00:27.080 --> 00:00:28.560
È più semplice di quanto pensi.
10
00:00:29.540 --> 00:00:30.780
Tutti entrano allo stesso modo.
11
00:00:31.320 --> 00:00:34.540
Una posizione su uno smart contract
collocata in una matrice a due colonne.
12
00:00:35.080 --> 00:00:37.820
Ti servono esattamente due persone che si
uniscano con il tuo link.
13
00:00:38.380 --> 00:00:39.620
Li chiamiamo i tuoi diretti.
14
00:00:40.240 --> 00:00:42.920
E una volta che ne hai due, la tua
posizione è qualificata.
15
00:00:43.420 --> 00:00:45.020
Questa è la linea di arrivo per la
larghezza.
16
00:00:45.820 --> 00:00:46.820
Non ti serve mai un terzo.
17
00:00:47.000 --> 00:00:49.320
Da lì, il build va verso il basso, non
all'esterno.
18
00:00:50.120 --> 00:00:51.760
I tuoi due trovano ciascuno i loro due.
19
00:00:51.820 --> 00:00:53.040
Quei quattro trovano i loro.
20
00:00:53.780 --> 00:00:58.740
E la matrice si riempie: due, quattro,
otto, sedici, con tutto il team che spinge
la
21
00:00:58.800 --> 00:00:59.800
stessa ondata.
22
00:01:00.340 --> 00:01:02.560
Vieni pagato in due modi, e sono diversi.
23
00:01:03.060 --> 00:01:05.440
Conoscere la differenza ti evita molta
confusione.
24
00:01:05.980 --> 00:01:07.720
Flusso uno, premi di ingresso.
25
00:01:08.320 --> 00:01:12.680
Ogni singola persona che si unisce tramite
il tuo link ti paga un premio di ingresso
che viene consegnato
26
00:01:12.760 --> 00:01:15.400
al tuo portafoglio nella stessa
transazione con cui si uniscono.
27
00:01:15.960 --> 00:01:18.180
Non solo i primi due, ma tutti.
28
00:01:18.740 --> 00:01:20.820
Flusso due, pagamenti di upgrade.
29
00:01:21.320 --> 00:01:22.880
Il programma ha otto livelli.
30
00:01:23.540 --> 00:01:27.720
Man mano che le persone sotto di te
acquistano livelli, ogni pagamento sale
nella matrice e
31
00:01:27.780 --> 00:01:30.300
si ferma su una posizione, quella alla
profondità corrispondente.
32
00:01:30.960 --> 00:01:35.400
Qualcuno a un livello sotto di te ti paga
quando acquista il livello due.
33
00:01:36.380 --> 00:01:40.080
Due livelli sotto, livello tre, e così
via, fino a otto livelli di profondità.
34
00:01:40.320 --> 00:01:44.020
Ogni persona sotto di te ti paga una volta
esattamente a un livello.
35
00:01:44.980 --> 00:01:47.840
Ecco la regola che decide se ricevi quei
soldi.
36
00:01:48.260 --> 00:01:53.720
Per ricevere un pagamento di livello, la
tua posizione deve essere qualificata, con
due diretti,
37
00:01:53.800 --> 00:01:55.260
e già al livello che viene acquistato.
38
00:01:55.680 --> 00:01:57.720
Se non lo sei, il pagamento non aspetta.
39
00:01:58.300 --> 00:02:01.880
Passa permanentemente alla prossima
posizione idonea sopra di te.
40
00:02:02.700 --> 00:02:03.700
Non è una punizione.
41
00:02:03.900 --> 00:02:06.280
È il contratto che mantiene ogni pagamento
in movimento.
42
00:02:07.120 --> 00:02:11.180
Il tuo compito è semplicemente rimanere un
livello avanti rispetto allo strato attivo
più profondo del tuo team.
43
00:02:12.180 --> 00:02:15.800
E la tua dashboard ti mostra letteralmente
quando sta per arrivare il momento.
44
00:02:16.700 --> 00:02:20.300
Quando due slot di qualcuno sono pieni e
un'altra persona si unisce al suo team,
45
00:02:20.360 --> 00:02:24.860
il contratto colloca quel nuovo membro nel
primo slot libero più in basso.
46
00:02:25.440 --> 00:02:26.580
Questo è lo spillover.
47
00:02:28.080 --> 00:02:32.280
Lo spillover riempie la tua struttura con
persone che non hai mai reclutato, e
ognuna di esse è
48
00:02:32.340 --> 00:02:34.800
un futuro reddito da upgrade per la tua
posizione.
49
00:02:35.280 --> 00:02:38.500
Ma ascolta bene, lo spillover non ti
qualifica.
50
00:02:38.960 --> 00:02:42.980
Solo le persone che si uniscono con il tuo
link contano come tuoi diretti.
51
00:02:43.860 --> 00:02:45.780
Lo spillover è un regalo per la tua
profondità.
52
00:02:46.600 --> 00:02:48.240
Il tuo due è ancora il tuo compito.
53
00:02:49.140 --> 00:02:52.020
Una volta qualificato, la raccomandazione
del team è semplice.
54
00:02:52.860 --> 00:02:54.080
Sposta il tuo impegno verso il basso.
55
00:02:54.900 --> 00:02:58.520
Aiuta il tuo due a ottenere il loro due
invece di accumulare più diretti su te
stesso.
56
00:02:59.540 --> 00:03:02.240
La tua pagina di invito gestisce
automaticamente tutto questo.
57
00:03:02.700 --> 00:03:08.220
Una volta raggiunto due su due, indirizza
i nuovi arrivi alla successiva persona
nella tua gamba che ne
58
00:03:08.280 --> 00:03:08.940
ha bisogno.
59
00:03:08.980 --> 00:03:13.320
E se qualcuno si unisce ancora tramite il
tuo link dopo, allora è un bonus, mai
60
00:03:13.360 --> 00:03:14.360
un problema.
61
00:03:14.400 --> 00:03:18.700
Tieni l'intera ricompensa di ingresso, e
loro scendono nella tua gamba come
profondità.
62
00:03:19.680 --> 00:03:20.780
Non c'è alcuna regola contro.
63
00:03:20.960 --> 00:03:22.560
C'è solo un posto più intelligente dove
mirare.
64
00:03:24.060 --> 00:03:26.820
Qui è dove RM Circle è diverso da
qualsiasi cosa tu abbia provato.
65
00:03:27.580 --> 00:03:29.260
La tua dashboard è il tuo co-pilota.
66
00:03:29.880 --> 00:03:34.400
Mostra il tuo team in tempo reale, i
pagamenti che si formano sotto di te,
esattamente quando fare upgrade
67
00:03:34.440 --> 00:03:37.460
per prenderli, e chi nel tuo team ha
bisogno di aiuto dopo.
68
00:03:38.400 --> 00:03:41.260
Con un sistema di messaggistica integrato,
verificato dal tuo wallet.
69
00:03:41.780 --> 00:03:43.560
E ogni membro ha la stessa dashboard.
70
00:03:43.640 --> 00:03:47.840
Quindi quando alleni il tuo due, non
dirgli solo cosa fare.
71
00:03:48.220 --> 00:03:51.640
Apri la loro dashboard con loro e
insegnagli a eseguire la stessa mossa per
il loro due.
72
00:03:52.560 --> 00:03:53.880
Non stai costruendo una downline.
73
00:03:54.100 --> 00:03:55.100
Stai formando coach.
74
00:03:55.100 --> 00:03:57.400
È questo che crea profondità e paga per
anni.
75
00:03:58.320 --> 00:03:59.760
Entrare richiede tre passaggi.
76
00:04:00.560 --> 00:04:02.240
Guarda i brevi training sul sito.
77
00:04:02.920 --> 00:04:05.280
Configura un wallet gratuito e caricalo
con POEL.
78
00:04:06.160 --> 00:04:08.500
Se sei nuovo, puoi acquistarlo con una
carta di debito.
79
00:04:09.120 --> 00:04:10.980
E iscriviti direttamente sul sito con un
tap.
80
00:04:11.660 --> 00:04:14.900
Verrai automaticamente inserito nel team e
la tua dashboard diventa attiva nel
81
00:04:14.940 --> 00:04:16.779
momento in cui la transazione viene
confermata.
82
00:04:17.700 --> 00:04:23.180
Ultima cosa: dato che lo facciamo in modo
onesto, questa è una criptovaluta su uno
83
00:04:23.300 --> 00:04:25.820
smart contract con un rischio reale.
84
00:04:26.460 --> 00:04:27.760
Nessun reddito è garantito.
85
00:04:28.140 --> 00:04:32.220
I risultati arrivano dal lavoro di
squadra, e dovresti usare solo denaro che
puoi permetterti
86
00:04:32.260 --> 00:04:33.100
di perdere.
87
00:04:33.140 --> 00:04:34.920
Ciò che è garantito è la matematica.
88
00:04:35.440 --> 00:04:36.680
Il codice non può essere modificato.
89
00:04:36.900 --> 00:04:38.440
Nessuna azienda detiene i fondi.
90
00:04:38.820 --> 00:04:41.480
E ogni pagamento mai effettuato è pubblico
sulla blockchain.
91
00:04:42.140 --> 00:04:43.140
Trova i tuoi due.
92
00:04:43.380 --> 00:04:44.760
Aiuta i tuoi due a trovare i loro.
93
00:04:45.200 --> 00:04:46.200
Insegna loro a insegnare.
94
00:04:46.680 --> 00:04:48.260
RM Circle dot team.
+2 -2
View File
@@ -8,7 +8,7 @@
<main> <main>
<section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Team training</div><h1 style="font-size:clamp(34px,5vw,54px);margin:8px 0 10px">Watch. Learn. <span class="gold">Duplicate.</span></h1><p>Short videos take you from zero to your Premium position — in order. Watch them all before you join, and share this page with your two directs when it's their turn.</p></div> <section class="section"><div class="wrap"><div class="section-head"><div class="eyebrow">Team training</div><h1 style="font-size:clamp(34px,5vw,54px);margin:8px 0 10px">Watch. Learn. <span class="gold">Duplicate.</span></h1><p>Short videos take you from zero to your Premium position — in order. Watch them all before you join, and share this page with your two directs when it's their turn.</p></div>
<div class="video-card" style="max-width:880px;margin:0 auto 26px"><div style="padding:6px 6px 0"><div class="eyebrow">Video 1 · 5 min · UPDATED</div><h2 style="margin:6px 0 6px">How the team build works</h2><p style="color:var(--muted);margin:0 0 12px">The full picture in five minutes: the 2-direct strategy, both income streams, the catch-or-pass rule, spillover, the moving link, and how your dashboard coaches you through all of it.</p></div><video class="video-frame" style="display:block;width:100%;height:auto;aspect-ratio:16/9" controls preload="metadata" poster="/v/rmc-team-overview-poster.jpg" src="/v/rmc-team-overview-f8856dc743.mp4"></video></div> <div class="video-card" style="max-width:880px;margin:0 auto 26px"><div style="padding:6px 6px 0"><div class="eyebrow">Video 1 · 5 min · UPDATED</div><h2 style="margin:6px 0 6px">How the team build works</h2><p style="color:var(--muted);margin:0 0 12px">The full picture in five minutes: the 2-direct strategy, both income streams, the catch-or-pass rule, spillover, the moving link, and how your dashboard coaches you through all of it.</p></div><video class="video-frame" style="display:block;width:100%;height:auto;aspect-ratio:16/9" controls preload="metadata" poster="/v/rmc-team-overview-poster.jpg" src="/v/rmc-team-overview-f8856dc743.mp4"><track kind="captions" srclang="en" label="English" src="/captions/rmc-team-overview-f8856dc743.en.vtt"><track kind="captions" srclang="it" label="Italiano" src="/captions/rmc-team-overview-f8856dc743.it.vtt"></video></div>
<div class="video-card" style="max-width:880px;margin:0 auto 26px"><div style="padding:6px 6px 0"><div class="eyebrow">Video 2 · 3 min</div><h2 style="margin:6px 0 6px">Create your MetaMask wallet</h2><p style="color:var(--muted);margin:0 0 12px">Install MetaMask safely and set up your wallet. Never share your Secret Recovery Phrase with anyone.</p></div><video class="video-frame" style="display:block;width:100%;height:auto;aspect-ratio:16/9" controls preload="metadata" poster="/training/create_metamask_wallet_poster.jpg" src="/training/create_metamask_wallet.mp4"></video></div> <div class="video-card" style="max-width:880px;margin:0 auto 26px"><div style="padding:6px 6px 0"><div class="eyebrow">Video 2 · 3 min</div><h2 style="margin:6px 0 6px">Create your MetaMask wallet</h2><p style="color:var(--muted);margin:0 0 12px">Install MetaMask safely and set up your wallet. Never share your Secret Recovery Phrase with anyone.</p></div><video class="video-frame" style="display:block;width:100%;height:auto;aspect-ratio:16/9" controls preload="metadata" poster="/training/create_metamask_wallet_poster.jpg" src="/training/create_metamask_wallet.mp4"></video></div>
@@ -104,4 +104,4 @@
</div></section> </div></section>
</main> </main>
<footer class="wrap disclaimer">This is an independent RM Circle Team Build training resource. Always confirm transaction details in your wallet before signing. Never disclose your Secret Recovery Phrase.<div class="footer-links"><a href="/">Strategy</a><a href="/start">Getting Started</a><a href="/contract">Contract Security</a><a href="/admin">Team Admin</a><a href="/disclaimer">Disclaimers</a><a href="/how-pay-works">How You Get Paid</a><a href="/tools">Promo Tools</a><a href="/privacy">Privacy</a><a href="/refunds">Refunds</a></div></footer> <footer class="wrap disclaimer">This is an independent RM Circle Team Build training resource. Always confirm transaction details in your wallet before signing. Never disclose your Secret Recovery Phrase.<div class="footer-links"><a href="/">Strategy</a><a href="/start">Getting Started</a><a href="/contract">Contract Security</a><a href="/admin">Team Admin</a><a href="/disclaimer">Disclaimers</a><a href="/how-pay-works">How You Get Paid</a><a href="/tools">Promo Tools</a><a href="/privacy">Privacy</a><a href="/refunds">Refunds</a></div></footer>
<script src="/track.js"></script><script src="/rmc-wallet.js"></script><script src="/inapp-browser.js"></script><script src="/training.js"></script><script src="/wallet-notice.js" defer></script><script src="/chat.js" defer></script><script src="/translate.js" defer></script><script src="/tg-app.js" defer></script><script src="/nav-dash.js" defer></script></body></html> <script src="/track.js"></script><script src="/rmc-wallet.js"></script><script src="/inapp-browser.js"></script><script src="/training.js"></script><script src="/wallet-notice.js" defer></script><script src="/chat.js" defer></script><script src="/translate.js" defer></script><script src="/vtt-lang.js" defer></script><script src="/tg-app.js" defer></script><script src="/nav-dash.js" defer></script></body></html>
+33
View File
@@ -0,0 +1,33 @@
// Match video captions to the language the member already chose with the 🌐 button.
//
// Deliberately NOT on by default for English. Someone reading English does not want
// captions forced over the picture; someone who switched the site to Italian almost
// certainly does. So: if their chosen language has a track, show it. Otherwise show
// nothing and leave the player's own CC button to do its job.
//
// Marty + Manson, 2026-09-17. See tools/captions.mjs for how the tracks are built.
(function () {
function pick() {
var lang = '';
try { lang = String(localStorage.getItem('rmc.lang') || '').toLowerCase(); } catch (e) {}
var vids = document.querySelectorAll('video');
for (var i = 0; i < vids.length; i++) {
var tt = vids[i].textTracks;
if (!tt || !tt.length) continue;
var wanted = null;
for (var j = 0; j < tt.length; j++) {
if (tt[j].kind !== 'captions' && tt[j].kind !== 'subtitles') continue;
// never leave a stale track showing when the member switches language
tt[j].mode = 'disabled';
if (lang && lang !== 'en' && String(tt[j].language || '').toLowerCase() === lang) wanted = tt[j];
}
if (wanted) wanted.mode = 'showing';
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', pick);
else pick();
// translate.js writes rmc.lang when the member picks a language; re-run so the
// captions follow without a reload, and cover players rendered later.
window.addEventListener('storage', function (e) { if (!e || e.key === 'rmc.lang') pick(); });
window.RMCCaptions = { refresh: pick };
})();
+63
View File
@@ -0,0 +1,63 @@
// Proves the caption tracks actually load and follow the member's chosen language.
// A .vtt served with the wrong Content-Type is silently ignored by the browser, so
// checking the markup is not enough: this reads the parsed cues back out of the player.
//
// Run: LOCAL=http://127.0.0.1:3399 node qa/captions-e2e.mjs
import { pathToFileURL } from 'node:url';
const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright';
const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default;
const B = process.env.LOCAL || 'http://127.0.0.1:3399';
const ok = [], bad = [];
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
const browser = await chromium.launch();
const VTT = '/captions/rmc-team-overview-f8856dc743';
// 1. the files are served as real VTT, not octet-stream (the silent killer)
for (const lang of ['en', 'it']) {
const r = await fetch(B + VTT + '.' + lang + '.vtt');
const ct = r.headers.get('content-type') || '';
const body = await r.text();
t('serves ' + lang + '.vtt as text/vtt', r.ok && /text\/vtt/.test(ct), r.status + ' ' + ct);
t(lang + '.vtt starts with WEBVTT', body.startsWith('WEBVTT'), body.slice(0, 20));
t(lang + '.vtt has cues with timings', (body.match(/-->/g) || []).length > 50, String((body.match(/-->/g) || []).length));
}
const load = async (lang) => {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
if (lang) await ctx.addInitScript(l => { try { localStorage.setItem('rmc.lang', l); } catch (e) {} }, lang);
const p = await ctx.newPage();
await p.goto(B + '/training', { waitUntil: 'domcontentloaded' });
await p.waitForTimeout(2500);
return { ctx, p };
};
const readTracks = p => p.evaluate(() => {
const v = document.querySelector('video[src*="rmc-team-overview"]');
if (!v) return null;
return Array.from(v.textTracks).map(tr => ({ lang: tr.language, mode: tr.mode, cues: tr.cues ? tr.cues.length : 0,
first: tr.cues && tr.cues[0] ? tr.cues[0].text : '' }));
});
// 2. an English member: tracks available, nothing forced on screen
let { ctx, p } = await load(null);
let tracks = await readTracks(p);
t('the overview player has caption tracks', !!tracks && tracks.length === 2, JSON.stringify(tracks));
t('no captions forced on for an English member', !!tracks && tracks.every(x => x.mode !== 'showing'), JSON.stringify(tracks && tracks.map(x => x.mode)));
await ctx.close();
// 3. a member who chose Italian: the Italian track is showing, with real parsed cues
({ ctx, p } = await load('it'));
// the browser only parses a track once it is enabled, so read after the matcher runs
await p.waitForTimeout(1500);
tracks = await readTracks(p);
const it = (tracks || []).find(x => x.lang === 'it');
const en = (tracks || []).find(x => x.lang === 'en');
t('Italian track is the one showing', !!it && it.mode === 'showing', JSON.stringify(tracks && tracks.map(x => x.lang + ':' + x.mode)));
t('English track is not also showing', !!en && en.mode !== 'showing', en && en.mode);
t('the Italian cues actually parsed', !!it && it.cues > 50, it && String(it.cues));
t('and they are Italian, not English passthrough', !!it && /parte|reclutano|squadra|team/i.test(it.first) && !/^Most team builds fail/.test(it.first), it && it.first);
await ctx.close();
console.log('PASS ' + ok.length);
for (const b of bad) console.log('FAIL ' + b);
await browser.close();
process.exit(bad.length ? 1 : 0);
+1 -1
View File
@@ -567,7 +567,7 @@ function parseCookies(req){const out={};for(const p of (req.headers.cookie||'').
function getSession(req){const token=parseCookies(req)['ctb.sid'];if(!token)return null;const s=sessions.get(token);if(!s)return null;if(s.expires<Date.now()){sessions.delete(token);return null}return {token,...s}} function getSession(req){const token=parseCookies(req)['ctb.sid'];if(!token)return null;const s=sessions.get(token);if(!s)return null;if(s.expires<Date.now()){sessions.delete(token);return null}return {token,...s}}
function requireAdmin(req,res){if(!getSession(req)){json(res,401,{error:'Unauthorized'});return false}return true} function requireAdmin(req,res){if(!getSession(req)){json(res,401,{error:'Unauthorized'});return false}return true}
async function bodyJson(req){return await new Promise((resolve,reject)=>{let data='';req.on('data',c=>{data+=c;if(data.length>100000){reject(new Error('Payload too large'));req.destroy()}});req.on('end',()=>{if(!data)return resolve({});try{resolve(JSON.parse(data))}catch(e){reject(new Error('Invalid JSON'))}});req.on('error',reject)})} async function bodyJson(req){return await new Promise((resolve,reject)=>{let data='';req.on('data',c=>{data+=c;if(data.length>100000){reject(new Error('Payload too large'));req.destroy()}});req.on('end',()=>{if(!data)return resolve({});try{resolve(JSON.parse(data))}catch(e){reject(new Error('Invalid JSON'))}});req.on('error',reject)})}
function contentType(file){const ext=path.extname(file);return ({'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'application/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.svg':'image/svg+xml','.ico':'image/x-icon','.mp4':'video/mp4','.webm':'video/webm'}[ext]||'application/octet-stream')} function contentType(file){const ext=path.extname(file);return ({'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'application/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.svg':'image/svg+xml','.ico':'image/x-icon','.vtt':'text/vtt; charset=utf-8','.mp4':'video/mp4','.webm':'video/webm'}[ext]||'application/octet-stream')}
function staticFile(req,res,file,status=200){ function staticFile(req,res,file,status=200){
if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false; if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;
const size=fs.statSync(file).size; const size=fs.statSync(file).size;
+194
View File
@@ -0,0 +1,194 @@
// Build WebVTT captions for a training video, in English and in any of the site's
// languages, WITHOUT re-rendering or re-voicing anything.
//
// Why this shape (Marty + Manson, 2026-09-17): Manson asked for the training videos in
// other languages. A real dub means re-rendering every video per language, because
// translated Romance-language speech runs 15-25% longer than English and these are slide
// videos with fixed beat timings, so swapped audio drifts off what is on screen. Captions
// keep ONE video file and add small text tracks beside it, so editing a lesson re-captions
// that lesson only.
//
// node tools/captions.mjs <video.mp4> [--langs it,fr,es,de] [--out public/captions]
//
// Steps: ffmpeg pulls the audio, ElevenLabs Scribe transcribes it with word timings
// (the same STT we already use to verify voiceovers), words are grouped into SENTENCES,
// and only then translated. Translating cue-by-cue produces nonsense at cue boundaries,
// which is the usual reason auto-captions read badly.
//
// Translation goes through the site's own /api/public/translate, so every phrase is
// cached forever in translations.json and costs once across the whole site.
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
const args = process.argv.slice(2);
const VIDEO = args.find(a => !a.startsWith('--'));
const argOf = (n, d) => { const a = args.find(x => x.startsWith('--' + n + '=')); return a ? a.split('=')[1] : d; };
const LANGS = argOf('langs', 'it').split(',').map(s => s.trim()).filter(Boolean);
const OUT = argOf('out', 'public/captions');
const SITE = argOf('site', 'https://rmcircle.team');
const FFDIR = argOf('ffmpeg', 'C:/Users/Marty/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0.1-full_build/bin');
const KEYFILE = argOf('key', 'D:/Projects/MarketingAgent/.elevenlabs-key');
if (!VIDEO) { console.error('usage: node tools/captions.mjs <video.mp4> [--langs it,fr] [--out dir]'); process.exit(2); }
const base = path.basename(VIDEO).replace(/\.mp4$/i, '');
const work = path.join('D:/tmp/captions', base);
fs.mkdirSync(work, { recursive: true });
fs.mkdirSync(OUT, { recursive: true });
// Two lines of about 42 characters is the readable ceiling for a video overlay.
const MAX_LINE = 42, MAX_LINES = 2, MIN_CUE = 1.0, MAX_CUE = 6.5;
// ---------- 1. transcribe (cached: STT costs money, never pay twice) ----------
const sttPath = path.join(work, 'stt.json');
if (!fs.existsSync(sttPath)) {
const mp3 = path.join(work, 'audio.mp3');
if (!fs.existsSync(mp3)) {
console.log('extracting audio...');
execFileSync(path.join(FFDIR, 'ffmpeg'), ['-y', '-i', VIDEO, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'libmp3lame', '-b:a', '64k', mp3], { stdio: 'ignore' });
}
const key = fs.readFileSync(KEYFILE, 'utf8').trim().replace(/^.*=/, '');
console.log('transcribing with scribe_v1...');
const fd = new FormData();
fd.append('model_id', 'scribe_v1');
fd.append('timestamps_granularity', 'word');
fd.append('language_code', 'eng');
fd.append('file', new Blob([fs.readFileSync(mp3)]), 'audio.mp3');
const r = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { method: 'POST', headers: { 'xi-api-key': key }, body: fd });
if (!r.ok) { console.error('STT failed', r.status, (await r.text()).slice(0, 300)); process.exit(1); }
fs.writeFileSync(sttPath, JSON.stringify(await r.json()));
}
const stt = JSON.parse(fs.readFileSync(sttPath, 'utf8'));
const words = (stt.words || []).filter(w => w.type === 'word' && String(w.text || '').trim());
console.log('words: ' + words.length + ' | audio ' + Math.round(stt.audio_duration_secs || 0) + 's');
// ---------- 2. words -> sentences (the unit we translate) ----------
const sentences = [];
let cur = null;
for (const w of words) {
if (!cur) cur = { start: w.start, end: w.end, words: [] };
cur.words.push(w); cur.end = w.end;
const endsSentence = /[.!?]["')\]]?$/.test(w.text);
const tooLong = cur.words.map(x => x.text).join(' ').length > 240;
if (endsSentence || tooLong) { sentences.push(cur); cur = null; }
}
if (cur) sentences.push(cur);
for (const s of sentences) s.text = s.words.map(w => w.text).join(' ').replace(/\s+([,.!?;:])/g, '$1').trim();
console.log('sentences: ' + sentences.length);
// ---------- 3. sentence -> cues, splitting only where a sentence is too long to show ----------
function wrap(text) {
const out = []; let line = '';
for (const w of text.split(/\s+/)) {
if (!line) line = w;
else if ((line + ' ' + w).length <= MAX_LINE) line += ' ' + w;
else { out.push(line); line = w; }
}
if (line) out.push(line);
return out;
}
// Chunk a sentence's words into display-sized pieces, keeping real timings.
function cuesFor(sent) {
// Chunk by the ACTUAL wrapped line count, not a character guess. A character cap of
// MAX_LINE * MAX_LINES lets text through that then wraps to three lines.
const fits = t => wrap(t).length <= MAX_LINES;
if (fits(sent.text) && (sent.end - sent.start) <= MAX_CUE) return [{ start: sent.start, end: sent.end, text: sent.text, sent }];
const pieces = []; let chunk = [];
for (const w of sent.words) {
const next = chunk.concat([w]);
const dur = w.end - next[0].start;
if (chunk.length && (!fits(next.map(x => x.text).join(' ')) || dur > MAX_CUE)) { pieces.push(chunk); chunk = [w]; }
else chunk = next;
}
if (chunk.length) {
// A one or two word tail reads as a glitch on screen ("sentence." alone), so fold it
// back into the previous cue whenever the result still fits.
const prev = pieces[pieces.length - 1];
if (prev && chunk.length <= 2 && fits(prev.concat(chunk).map(x => x.text).join(' '))) pieces[pieces.length - 1] = prev.concat(chunk);
else pieces.push(chunk);
}
return pieces.map(p => ({ start: p[0].start, end: p[p.length - 1].end, text: p.map(x => x.text).join(' ').replace(/\s+([,.!?;:])/g, '$1'), sent }));
}
let cues = [];
for (const s of sentences) cues = cues.concat(cuesFor(s));
// never flash a cue, and never let one overlap the next
for (let i = 0; i < cues.length; i++) {
if (cues[i].end - cues[i].start < MIN_CUE) cues[i].end = cues[i].start + MIN_CUE;
if (i + 1 < cues.length && cues[i].end > cues[i + 1].start) cues[i].end = Math.max(cues[i].start + 0.4, cues[i + 1].start - 0.04);
}
console.log('cues: ' + cues.length);
const ts = s => { const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60;
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + x.toFixed(3).padStart(6, '0'); };
// NEVER slice the wrapped lines here. Truncating to MAX_LINES silently DELETED words:
// "the whole plan fits in one sentence" shipped as "fits in" then "sentence". Chunking
// below keeps cues to two lines almost always; a rare third line beats a lost word.
const toVtt = list => 'WEBVTT\n\n' + list.map((c, i) =>
(i + 1) + '\n' + ts(c.start) + ' --> ' + ts(c.end) + '\n' + wrap(c.text).join('\n') + '\n').join('\n');
fs.writeFileSync(path.join(OUT, base + '.en.vtt'), toVtt(cues));
console.log('wrote ' + base + '.en.vtt');
// ---------- 4. translate whole sentences, then lay them back over the same timings ----------
async function translate(texts, tl) {
const out = [];
for (let i = 0; i < texts.length; i += 20) {
const batch = texts.slice(i, i + 20).map(t => t.slice(0, 300));
let got = null;
for (let attempt = 0; attempt < 3 && !got; attempt++) {
try {
const r = await fetch(SITE + '/api/public/translate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tl, texts: batch }) });
if (r.status === 429) { console.log(' rate limited, waiting 45s'); await new Promise(z => setTimeout(z, 45000)); continue; }
const d = await r.json();
// the endpoint answers { t: [...] }, and on failure it passes the ENGLISH back
// rather than erroring, so count identical strings and warn instead of shipping
// a "translated" track that is really English.
if (Array.isArray(d.t)) got = d.t;
else { console.error(' unexpected reply', JSON.stringify(d).slice(0, 200)); }
} catch (e) { console.error(' translate error', e.message); await new Promise(z => setTimeout(z, 3000)); }
}
if (!got) { console.error('translation failed for batch at ' + i); process.exit(1); }
out.push(...got);
process.stdout.write(' ' + Math.min(i + 20, texts.length) + '/' + texts.length + '\r');
}
console.log('');
return out;
}
// Spread one translated sentence across that sentence's cues, in proportion to how much
// of the English sentence each cue carried. Word counts differ between languages, so this
// is an approximation, but it keeps text on screen while its own audio is playing.
function spread(translated, sentCues) {
if (sentCues.length === 1) return [translated];
const words = translated.split(/\s+/);
const weights = sentCues.map(c => c.text.length);
const total = weights.reduce((a, b) => a + b, 0) || 1;
const out = []; let idx = 0;
sentCues.forEach((c, i) => {
const take = i === sentCues.length - 1 ? words.length - idx : Math.max(1, Math.round(words.length * weights[i] / total));
out.push(words.slice(idx, idx + take).join(' ')); idx += take;
});
return out;
}
for (const tl of LANGS) {
console.log('translating to ' + tl + '...');
const src = sentences.map(s => s.text);
const tr = await translate(src, tl);
const same = tr.filter((t, i) => String(t).trim() === String(src[i]).trim()).length;
if (same > src.length * 0.2) {
console.error('REFUSING ' + tl + ': ' + same + ' of ' + src.length + ' sentences came back identical to English. ' +
'The endpoint returns the original when the model call fails, so this track would be English wearing a ' + tl + ' label.');
continue;
}
if (same) console.log(' note: ' + same + ' sentence(s) unchanged (short or brand-only lines)');
const bySent = new Map();
cues.forEach(c => { if (!bySent.has(c.sent)) bySent.set(c.sent, []); bySent.get(c.sent).push(c); });
const translatedCues = [];
sentences.forEach((s, i) => {
const sc = bySent.get(s) || [];
const parts = spread(String(tr[i] || s.text), sc);
sc.forEach((c, j) => translatedCues.push({ start: c.start, end: c.end, text: parts[j] || '' }));
});
fs.writeFileSync(path.join(OUT, base + '.' + tl + '.vtt'), toVtt(translatedCues.filter(c => c.text.trim())));
console.log('wrote ' + base + '.' + tl + '.vtt');
}
console.log('done');