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:
@@ -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.
|
||||
@@ -8,7 +8,7 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -104,4 +104,4 @@
|
||||
</div></section>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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 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)})}
|
||||
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){
|
||||
if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;
|
||||
const size=fs.statSync(file).size;
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user