This commit is contained in:
itten
2026-08-02 11:32:45 +03:00
commit cf7b8e1a5d
44 changed files with 1347 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
# Two-Stands Transfers — Broadcast Graphics Template
Soccer player transfer broadcast graphics — **transparent overlay** for **1920×1080** frames.
## Specifications
| Property | State 1 (Reveal) | State 2 (Compact) |
|-------------------|------------------|------------------------|
| Frame | 1920×1080 px (transparent) | 1920×1080 px (transparent) |
| Graphics area | 1920×758 px | 641×758 px (centered) |
| Duration | 5 seconds | holds until next |
| Player photo | 641×758 PNG (1:1) | 230px crop from 641-wide source |
## Layout — State 1 (05 s)
Full-width 1920×758 bar:
```
┌─────────────────── 1920×758 ──────────────────────────────┐
│ │
│ ┌──────────┐ ПЕРЕХОД │
│ │ │ │
│ │ PLAYER │ PLAYER NAME │
│ │ PHOTO │ │
│ │ │ │
│ │ 641px │ [MLS] ЧЕЛСИ ┌──────────┐ │
│ │ (1:1) │ │ 138 млн │ │
│ │ │ └──────────┘ │
│ └──────────┘ │
└──────────────────────────────────────────────────────────┘
```
## Layout — State 2 (after 5 s)
Shrinks to centered 641×758:
```
┌──────── 641×758 ────────┐
│ │
│ ┌────────┐ ПЕРЕХОД │
│ │ PHOTO │ PLAYER NAME │
│ │ 230px │ │
│ │ │ ЧЕЛСИ │
│ │ │ ┌────────┐ │
│ └────────┘ │ 138 млн│ │
│ └────────┘ │
└─────────────────────────┘
```
## Data — `data/transfers.json`
Generated from `assets/transfers-list.csv`. Each transfer object:
```json
{
"id": 1,
"playerName": "Морган Роджерс",
"photo": "assets/players/player_01.png",
"team": {
"name": "Челси",
"logo": "assets/teams/chelsea.svg",
"color": "#034694",
"accent": "#FFFFFF"
},
"price": "138 млн",
"isFreeAgent": false
}
```
For free agents (`isFreeAgent: true`), the price badge turns green and the
label shows "Статус" instead of "Сумма".
### Data source — `assets/transfers-list.csv`
```
Player, Club, Sum
Морган Роджерс, Челси, 138 млн
Энтони Гордон, Барселона, 80 млн
Ибрагим Конате, Реал, Свободный агент
```
Edit the CSV, then re-run the generator to rebuild `transfers.json`
and regenerate placeholder PNGs + team crests.
## Usage
### 1. Run a local server (needed for fetch)
```bash
python3 -m http.server 8000
```
### 2. Open in browser
```
http://localhost:8000
```
### 3. Controls
| Key | Action |
|----------------|---------------------------|
| → / Enter | Next transfer |
| ← / Backspace | Previous transfer |
| R | Replay current |
| H | Hide / Show |
| A | Toggle autoplay (8s loop) |
### External API (CasparCG / OBS)
```javascript
window.transferGraphics.showAt(0); // show transfer #1 (starts State 1)
window.transferGraphics.next(); // next transfer
window.transferGraphics.prev(); // previous transfer
window.transferGraphics.replay(); // replay current
window.transferGraphics.hide(); // hide graphics
window.transferGraphics.show(); // show graphics
window.transferGraphics.autoplay(); // toggle autoplay
```
## Generating Placeholder Assets
```bash
python3 generate_placeholders.py
```
This reads `assets/transfers-list.csv` and generates:
- `assets/teams/*.svg` — team crest placeholders (colored shields)
- `assets/players/player_*.png` — player PNG placeholders at **641×758**
- `data/transfers.json` — the transfer data file
**Replace** the placeholder PNGs with real 641×758 player cutouts — just
drop your files into `assets/players/` with the same names (`player_01.png`,
`player_02.png`, …). No scaling needed — the source image is displayed 1:1
in State 1, and CSS `object-fit: cover` crops it for the compact State 2.
## Timing
```
0.0 s Card enters (slide + fade)
0.15s Photo scales in
0.35s "ПЕРЕХОД" label slides down
0.55s Player name slides in
1.0 s Team block appears
1.35s Price badge pops in (bounce)
5.0 s Container shrinks 1920 → 641 (0.85 s smooth transition)
5.85s+ Compact state holds
```
+35
View File
@@ -0,0 +1,35 @@
Player, Club, Sum, Photo, Logo
Морган Роджерс,Челси,138 млн,tmp-player.png,chelsea.png
Эллиот Андерсон,Манчестер Сити,135 млн,tmp-player.png,manchester-city.png
Сандро Тонали,Тоттенхэм,108 млн,tmp-player.png,tottenham.png
Матеус Фернандес,Тоттенхэм,99 млн,tmp-player.png,tottenham.png
Энтони Гордон,Барселона,80 млн,tmp-player.png,barcelona.png
Гонсало Рамуш,Милан,74 млн,tmp-player.png,ajax.png
Самервилл,Аль-Хилаль,"64,5 млн",tmp-player.png,ajax.png
Жереми Жаке,Ливерпуль,"63,6 млн",tmp-player.png,liverpool.png
Ван Хекке,Тоттенхэм,60 млн,tmp-player.png,tottenham.png
Йохан Манзамби,Астон Вилла,60 млн,tmp-player.png,ajax.png
Лука Вушкович,Брайтон,54 млн,tmp-player.png,chelsea.png
Марко Палестра,Челси,57 млн,tmp-player.png,chelsea.png
Андре Сантос,Манчестер Юнайтед,"56,3 млн",tmp-player.png,chelsea.png
Марк Кукурелья,Реал,50 млн,tmp-player.png,chelsea.png
Натаниэль Браун,Бавария,50 млн,tmp-player.png,chelsea.png
Исмаэль Сайбари,Бавария,50 млн,tmp-player.png,chelsea.png
Ибрагим Конате,Реал,Свободный агент,tmp-player.png,chelsea.png
Опенда,Ювентус,"42,75 млн",tmp-player.png,chelsea.png
Тилеманс,Манчестер Юнайтед,41 млн,tmp-player.png,chelsea.png
Христос Цолис,Арсенал,40 млн,tmp-player.png,chelsea.png
Кан Ли,Атлетико,40 млн,tmp-player.png,chelsea.png
Карим Адейеми,Барселона,22 млн,tmp-player.png,chelsea.png
Гарначо,Астон Вилла,Аренда,tmp-player.png,chelsea.png
Александар Станкович,Интер,23 млн,tmp-player.png,chelsea.png
Думфрис,Реал,20 млн,tmp-player.png,chelsea.png
Гримальдо,Атлетико,22 млн,tmp-player.png,chelsea.png
Бернардо Силва,Реал,Свободный агент,tmp-player.png,chelsea.png
Оскар Мингеса,Кристал Пэлас,Свободный агент,tmp-player.png,chelsea.png
Аканжи,Интер,15 млн,tmp-player.png,chelsea.png
Альваро Мората,Комо,12 млн,tmp-player.png,chelsea.png
1 Player Club Sum Photo Logo
2 Морган Роджерс Челси 138 млн tmp-player.png chelsea.png
3 Эллиот Андерсон Манчестер Сити 135 млн tmp-player.png manchester-city.png
4 Сандро Тонали Тоттенхэм 108 млн tmp-player.png tottenham.png
5 Матеус Фернандес Тоттенхэм 99 млн tmp-player.png tottenham.png
6 Энтони Гордон Барселона 80 млн tmp-player.png barcelona.png
7 Гонсало Рамуш Милан 74 млн tmp-player.png ajax.png
8 Самервилл Аль-Хилаль 64,5 млн tmp-player.png ajax.png
9 Жереми Жаке Ливерпуль 63,6 млн tmp-player.png liverpool.png
10 Ван Хекке Тоттенхэм 60 млн tmp-player.png tottenham.png
11 Йохан Манзамби Астон Вилла 60 млн tmp-player.png ajax.png
12 Лука Вушкович Брайтон 54 млн tmp-player.png chelsea.png
13 Марко Палестра Челси 57 млн tmp-player.png chelsea.png
14 Андре Сантос Манчестер Юнайтед 56,3 млн tmp-player.png chelsea.png
15 Марк Кукурелья Реал 50 млн tmp-player.png chelsea.png
16 Натаниэль Браун Бавария 50 млн tmp-player.png chelsea.png
17 Исмаэль Сайбари Бавария 50 млн tmp-player.png chelsea.png
18 Ибрагим Конате Реал Свободный агент tmp-player.png chelsea.png
19 Опенда Ювентус 42,75 млн tmp-player.png chelsea.png
20 Тилеманс Манчестер Юнайтед 41 млн tmp-player.png chelsea.png
21 Христос Цолис Арсенал 40 млн tmp-player.png chelsea.png
22 Кан Ли Атлетико 40 млн tmp-player.png chelsea.png
23 Карим Адейеми Барселона 22 млн tmp-player.png chelsea.png
24 Гарначо Астон Вилла Аренда tmp-player.png chelsea.png
25 Александар Станкович Интер 23 млн tmp-player.png chelsea.png
26 Думфрис Реал 20 млн tmp-player.png chelsea.png
27 Гримальдо Атлетико 22 млн tmp-player.png chelsea.png
28 Бернардо Силва Реал Свободный агент tmp-player.png chelsea.png
29 Оскар Мингеса Кристал Пэлас Свободный агент tmp-player.png chelsea.png
30 Аканжи Интер 15 млн tmp-player.png chelsea.png
31 Альваро Мората Комо 12 млн tmp-player.png chelsea.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+324
View File
@@ -0,0 +1,324 @@
/* ==========================================================================
TRANSFER Broadcast Graphics Template (transparent overlay)
Frame: 1920 x 1080 px (transparent for compositor / OBS / CasparCG)
Graphics: 1920 x 758 px -> 641 x 758 px (center, after 5s)
Player photo source: 641 x 758 px PNG (displayed 1:1 in State 1)
========================================================================== */
:root {
--team-color: #FEBE10;
--team-accent: #00529F;
--gfx-h: 758px;
--gfx-w-full: 1920px;
--gfx-w-mini: 641px;
--photo-w: 641px; /* source image width — 1:1 in State 1 */
--photo-w-c: 250px; /* compact State 2 width (cropped from 641 source) */
--ease: cubic-bezier(0.45, 0, 0.15, 1);
--bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--speed: 1.0s;
--white: #FFFFFF;
--dark: #0F0F14;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1920px;
height: 1080px;
background: transparent;
overflow: hidden;
font-family: 'ONY One', 'Barlow Condensed', 'Arial Narrow', sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Frame ============================================================== */
.frame {
position: relative;
width: 1920px;
height: 1080px;
background: transparent; /* overlay mode — no background */
overflow: hidden;
}
/* Graphics container ================================================= */
.graphics-container {
position: absolute;
bottom: 161px; /* (1080 - 758) / 2 = 161 -> vertically centered */
left: 50%;
transform: translateX(-50%);
width: 1920px;
height: 758px;
overflow: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease, width var(--speed) var(--ease);
}
.graphics-container.visible {
opacity: 1;
}
.graphics-container.hidden {
opacity: 0;
}
.graphics-container.compact {
width: 641px;
}
/* Transfer card ====================================================== */
.transfer-card {
width: 100%;
height: 100%;
display: flex;
background: #222524;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
position: relative;
overflow: hidden;
transition: background var(--speed) var(--ease);
}
/* Photo section ====================================================== */
.photo-section {
flex: 0 0 var(--photo-w);
width: var(--photo-w);
height: 100%;
position: relative;
overflow: hidden;
transition: flex-basis var(--speed) var(--ease),
width var(--speed) var(--ease);
}
.compact .photo-section {
flex: 0 0 var(--photo-w-c);
width: var(--photo-w-c);
}
.player-photo {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
display: block;
transition: object-position var(--speed) var(--ease);
}
.photo-overlay {
position: absolute; inset: 0;
background: linear-gradient(180deg,
transparent 0%,
transparent 55%,
rgba(0, 0, 0, 0.4) 100%);
pointer-events: none;
}
/* Info section ======================================================= */
.info-section {
flex: 1 1 auto;
min-width: 0;
height: 100%;
padding: 55px 70px 50px;
display: flex;
flex-direction: column;
justify-content: center; /* always center — never changes */
position: relative;
z-index: 3;
transition: padding var(--speed) var(--ease);
}
.compact .info-section {
padding: 50px 35px 50px;
}
/* Player name ---------------------------------------------------- */
.player-name {
font-size: 72px;
font-weight: 900;
line-height: 0.95;
text-transform: uppercase;
color: var(--white);
text-shadow: 3px 3px 0 rgba(0, 0, 0, 0.22);
margin-bottom: 36px;
transition: font-size var(--speed) var(--ease),
line-height var(--speed) var(--ease),
margin var(--speed) var(--ease);
}
.compact .player-name {
font-size: 48px;
line-height: 0.95;
margin-bottom: 40px;
}
/* Team block ------------------------------------------------------ */
.team-block {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 24px;
transition: gap var(--speed) var(--ease),
margin var(--speed) var(--ease);
}
.compact .team-block {
gap: 14px;
margin-bottom: 30px;
}
.team-logo {
width: 72px;
height: 72px;
object-fit: contain;
flex-shrink: 0;
transition: width var(--speed) var(--ease), height var(--speed) var(--ease);
}
.compact .team-logo { width: 56px; height: 56px; }
.team-text { display: flex; flex-direction: column; }
.team-direction {
font-size: 14px;
font-weight: 600;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--white);
opacity: 0.5;
transition: font-size var(--speed) var(--ease),
letter-spacing var(--speed) var(--ease);
}
.compact .team-direction { font-size: 13px; letter-spacing: 3px; }
.team-name {
font-size: 28px;
font-weight: 700;
text-transform: uppercase;
color: var(--white);
line-height: 1;
margin-top: 4px;
transition: font-size var(--speed) var(--ease);
}
.compact .team-name { font-size: 22px; }
/* Price ----------------------------------------------------------- */
.price-badge {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-shrink: 0;
transition: align-self var(--speed) var(--ease);
}
.compact .price-badge {
text-align: left;
}
.price-label {
font-size: 16px;
font-weight: 600;
letter-spacing: 4px;
text-transform: uppercase;
color: var(--white);
opacity: 0.5;
transition: font-size var(--speed) var(--ease),
letter-spacing var(--speed) var(--ease);
}
.compact .price-label { font-size: 13px; letter-spacing: 3px; }
.price-value {
font-size: 46px;
font-weight: 900;
text-transform: uppercase;
color: var(--white);
line-height: 1;
margin-top: 4px;
white-space: nowrap;
transition: font-size var(--speed) var(--ease),
color var(--speed) var(--ease),
margin var(--speed) var(--ease);
}
.compact .price-value { font-size: 38px; }
/* ========================================================================
Reveal animation (staggered entrance)
======================================================================== */
.reveal .transfer-card { animation: cardIn 0.5s var(--ease) both; }
.reveal .photo-section { animation: photoIn 0.6s var(--ease) 0.15s both; }
.reveal .player-name { animation: slideRight 0.5s var(--ease) 0.55s both; }
.reveal .team-block { animation: slideRight 0.4s var(--ease) 1.0s both; }
.reveal .price-badge { animation: slideRight 0.4s var(--ease) 1.3s both; }
@keyframes cardIn {
from { transform: translateY(30px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes photoIn {
from { transform: scale(1.15); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-15px); opacity: 0; }
to { transform: translateY(0); opacity: 0.55; }
}
@keyframes slideRight {
from { transform: translateX(-25px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes popIn {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
/* ========================================================================
Operator panel (dev overlay)
======================================================================== */
.operator-panel {
position: absolute;
top: 15px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: rgba(0, 0, 0, 0.75);
color: #eee;
padding: 10px 24px;
border-radius: 6px;
font-family: 'ONY One', sans-serif;
font-size: 16px;
z-index: 100;
opacity: 0.25;
transition: opacity 0.2s;
pointer-events: none;
}
.operator-panel:hover { opacity: 0.95; }
.op-counter {
font-size: 20px;
font-weight: 700;
color: #FEBE10;
}
.op-controls { font-size: 14px; color: #aaa; }
kbd {
background: #333;
border: 1px solid #555;
border-radius: 3px;
padding: 1px 7px;
font-family: monospace;
font-size: 13px;
color: #fff;
}
+394
View File
@@ -0,0 +1,394 @@
{
"transfers": [
{
"id": 1,
"playerName": "Морган Роджерс",
"photo": "tmp-player.png",
"team": {
"name": "Челси",
"logo": "clubs-logo/chelsea.png",
"color": "#034694",
"accent": "#FFFFFF"
},
"price": "138 млн",
"status": "fee"
},
{
"id": 2,
"playerName": "Эллиот Андерсон",
"photo": "tmp-player.png",
"team": {
"name": "Манчестер Сити",
"logo": "clubs-logo/manchester-city.png",
"color": "#6CABDD",
"accent": "#1C2C5B"
},
"price": "135 млн",
"status": "fee"
},
{
"id": 3,
"playerName": "Сандро Тонали",
"photo": "tmp-player.png",
"team": {
"name": "Тоттенхэм",
"logo": "clubs-logo/tottenham.png",
"color": "#132257",
"accent": "#FFFFFF"
},
"price": "108 млн",
"status": "fee"
},
{
"id": 4,
"playerName": "Матеус Фернандес",
"photo": "tmp-player.png",
"team": {
"name": "Тоттенхэм",
"logo": "clubs-logo/tottenham.png",
"color": "#132257",
"accent": "#FFFFFF"
},
"price": "99 млн",
"status": "fee"
},
{
"id": 5,
"playerName": "Энтони Гордон",
"photo": "tmp-player.png",
"team": {
"name": "Барселона",
"logo": "clubs-logo/barcelona.png",
"color": "#A50044",
"accent": "#004D98"
},
"price": "80 млн",
"status": "fee"
},
{
"id": 6,
"playerName": "Гонсало Рамуш",
"photo": "tmp-player.png",
"team": {
"name": "Милан",
"logo": "clubs-logo/ajax.png",
"color": "#FB090B",
"accent": "#000000"
},
"price": "74 млн",
"status": "fee"
},
{
"id": 7,
"playerName": "Самервилл",
"photo": "tmp-player.png",
"team": {
"name": "Аль-Хилаль",
"logo": "clubs-logo/ajax.png",
"color": "#1B458F",
"accent": "#FFFFFF"
},
"price": "64,5 млн",
"status": "fee"
},
{
"id": 8,
"playerName": "Жереми Жаке",
"photo": "tmp-player.png",
"team": {
"name": "Ливерпуль",
"logo": "clubs-logo/liverpool.png",
"color": "#C8102E",
"accent": "#FFFFFF"
},
"price": "63,6 млн",
"status": "fee"
},
{
"id": 9,
"playerName": "Ван Хекке",
"photo": "tmp-player.png",
"team": {
"name": "Тоттенхэм",
"logo": "clubs-logo/tottenham.png",
"color": "#132257",
"accent": "#FFFFFF"
},
"price": "60 млн",
"status": "fee"
},
{
"id": 10,
"playerName": "Йохан Манзамби",
"photo": "tmp-player.png",
"team": {
"name": "Астон Вилла",
"logo": "clubs-logo/ajax.png",
"color": "#95BFE5",
"accent": "#670E36"
},
"price": "60 млн",
"status": "fee"
},
{
"id": 11,
"playerName": "Лука Вушкович",
"photo": "tmp-player.png",
"team": {
"name": "Брайтон",
"logo": "clubs-logo/chelsea.png",
"color": "#0057B8",
"accent": "#FFCD00"
},
"price": "54 млн",
"status": "fee"
},
{
"id": 12,
"playerName": "Марко Палестра",
"photo": "tmp-player.png",
"team": {
"name": "Челси",
"logo": "clubs-logo/chelsea.png",
"color": "#034694",
"accent": "#FFFFFF"
},
"price": "57 млн",
"status": "fee"
},
{
"id": 13,
"playerName": "Андре Сантос",
"photo": "tmp-player.png",
"team": {
"name": "Манчестер Юнайтед",
"logo": "clubs-logo/chelsea.png",
"color": "#DA291C",
"accent": "#FFE500"
},
"price": "56,3 млн",
"status": "fee"
},
{
"id": 14,
"playerName": "Марк Кукурелья",
"photo": "tmp-player.png",
"team": {
"name": "Реал",
"logo": "clubs-logo/chelsea.png",
"color": "#FEBE10",
"accent": "#00529F"
},
"price": "50 млн",
"status": "fee"
},
{
"id": 15,
"playerName": "Натаниэль Браун",
"photo": "tmp-player.png",
"team": {
"name": "Бавария",
"logo": "clubs-logo/chelsea.png",
"color": "#DC052D",
"accent": "#0066B2"
},
"price": "50 млн",
"status": "fee"
},
{
"id": 16,
"playerName": "Исмаэль Сайбари",
"photo": "tmp-player.png",
"team": {
"name": "Бавария",
"logo": "clubs-logo/chelsea.png",
"color": "#DC052D",
"accent": "#0066B2"
},
"price": "50 млн",
"status": "fee"
},
{
"id": 17,
"playerName": "Ибрагим Конате",
"photo": "tmp-player.png",
"team": {
"name": "Реал",
"logo": "clubs-logo/chelsea.png",
"color": "#FEBE10",
"accent": "#00529F"
},
"price": "Свободный агент",
"status": "free-agent"
},
{
"id": 18,
"playerName": "Опенда",
"photo": "tmp-player.png",
"team": {
"name": "Ювентус",
"logo": "clubs-logo/chelsea.png",
"color": "#000000",
"accent": "#FFFFFF"
},
"price": "42,75 млн",
"status": "fee"
},
{
"id": 19,
"playerName": "Тилеманс",
"photo": "tmp-player.png",
"team": {
"name": "Манчестер Юнайтед",
"logo": "clubs-logo/chelsea.png",
"color": "#DA291C",
"accent": "#FFE500"
},
"price": "41 млн",
"status": "fee"
},
{
"id": 20,
"playerName": "Христос Цолис",
"photo": "tmp-player.png",
"team": {
"name": "Арсенал",
"logo": "clubs-logo/chelsea.png",
"color": "#EF0107",
"accent": "#FFFFFF"
},
"price": "40 млн",
"status": "fee"
},
{
"id": 21,
"playerName": "Кан Ли",
"photo": "tmp-player.png",
"team": {
"name": "Атлетико",
"logo": "clubs-logo/chelsea.png",
"color": "#CB3524",
"accent": "#261E60"
},
"price": "40 млн",
"status": "fee"
},
{
"id": 22,
"playerName": "Карим Адейеми",
"photo": "tmp-player.png",
"team": {
"name": "Барселона",
"logo": "clubs-logo/chelsea.png",
"color": "#A50044",
"accent": "#004D98"
},
"price": "22 млн",
"status": "fee"
},
{
"id": 23,
"playerName": "Гарначо",
"photo": "tmp-player.png",
"team": {
"name": "Астон Вилла",
"logo": "clubs-logo/chelsea.png",
"color": "#95BFE5",
"accent": "#670E36"
},
"price": "Аренда",
"status": "loan"
},
{
"id": 24,
"playerName": "Александар Станкович",
"photo": "tmp-player.png",
"team": {
"name": "Интер",
"logo": "clubs-logo/chelsea.png",
"color": "#0068A8",
"accent": "#000000"
},
"price": "23 млн",
"status": "fee"
},
{
"id": 25,
"playerName": "Думфрис",
"photo": "tmp-player.png",
"team": {
"name": "Реал",
"logo": "clubs-logo/chelsea.png",
"color": "#FEBE10",
"accent": "#00529F"
},
"price": "20 млн",
"status": "fee"
},
{
"id": 26,
"playerName": "Гримальдо",
"photo": "tmp-player.png",
"team": {
"name": "Атлетико",
"logo": "clubs-logo/chelsea.png",
"color": "#CB3524",
"accent": "#261E60"
},
"price": "22 млн",
"status": "fee"
},
{
"id": 27,
"playerName": "Бернардо Силва",
"photo": "tmp-player.png",
"team": {
"name": "Реал",
"logo": "clubs-logo/chelsea.png",
"color": "#FEBE10",
"accent": "#00529F"
},
"price": "Свободный агент",
"status": "free-agent"
},
{
"id": 28,
"playerName": "Оскар Мингеса",
"photo": "tmp-player.png",
"team": {
"name": "Кристал Пэлас",
"logo": "clubs-logo/chelsea.png",
"color": "#1B458F",
"accent": "#C4122E"
},
"price": "Свободный агент",
"status": "free-agent"
},
{
"id": 29,
"playerName": "Аканжи",
"photo": "tmp-player.png",
"team": {
"name": "Интер",
"logo": "clubs-logo/chelsea.png",
"color": "#0068A8",
"accent": "#000000"
},
"price": "15 млн",
"status": "fee"
},
{
"id": 30,
"playerName": "Альваро Мората",
"photo": "tmp-player.png",
"team": {
"name": "Комо",
"logo": "clubs-logo/chelsea.png",
"color": "#0033A0",
"accent": "#FFFFFF"
},
"price": "12 млн",
"status": "fee"
}
]
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Generate data/transfers.json from assets/transfers-list.csv.
CSV columns: Player, Club, Sum, Photo, Logo
- Photo: filename of the player photo at project root (e.g. tmp-player.png)
- Logo: filename of the club logo in clubs-logo/ (e.g. chelsea.png)
Team colours are looked up from the CLUBS dict below; unknown clubs
get auto-generated colours.
Run: python3 generate_placeholders.py
"""
import csv
import json
import os
import colorsys
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
CSV_PATH = os.path.join(BASE_DIR, "assets", "transfers-list.csv")
CLUBS_DIR = os.path.join(BASE_DIR, "clubs-logo")
os.makedirs(DATA_DIR, exist_ok=True)
# ---------------------------------------------------------------------------
# Russian club name -> colours
# ---------------------------------------------------------------------------
CLUBS = {
"Челси": {"color": "#034694", "accent": "#FFFFFF"},
"Барселона": {"color": "#A50044", "accent": "#004D98"},
"Реал": {"color": "#FEBE10", "accent": "#00529F"},
"Арсенал": {"color": "#EF0107", "accent": "#FFFFFF"},
"Бавария": {"color": "#DC052D", "accent": "#0066B2"},
"ПСЖ": {"color": "#004170", "accent": "#DA291C"},
"Ман Сити": {"color": "#6CABDD", "accent": "#1C2C5B"},
"Ливерпуль": {"color": "#C8102E", "accent": "#FFFFFF"},
"Ювентус": {"color": "#000000", "accent": "#FFFFFF"},
"Интер": {"color": "#0068A8", "accent": "#000000"},
"Милан": {"color": "#FB090B", "accent": "#000000"},
"Атлетико": {"color": "#CB3524", "accent": "#261E60"},
"Тоттенхэм": {"color": "#132257", "accent": "#FFFFFF"},
"Ньюкасл": {"color": "#241F20", "accent": "#FFFFFF"},
"Вест Хэм": {"color": "#7A263A", "accent": "#1BB1E7"},
"Брайтон": {"color": "#0057B8", "accent": "#FFCD00"},
"Байер": {"color": "#E32221", "accent": "#000000"},
"Айнтрахт": {"color": "#000000", "accent": "#E1000F"},
"Олимпиакос": {"color": "#E30613", "accent": "#FFFFFF"},
"Галатасарай":{"color": "#A90432", "accent": "#FCB330"},
"Аталанта": {"color": "#1E71B8", "accent": "#000000"},
"Наполи": {"color": "#12A0E0", "accent": "#FFFFFF"},
"Брюгге": {"color": "#0066B2", "accent": "#000000"},
"Боде/Глимт": {"color": "#FCD116", "accent": "#000000"},
"Унион": {"color": "#EA2127", "accent": "#FFFFFF"},
"Копенгаген": {"color": "#000000", "accent": "#FFFFFF"},
"Пафос": {"color": "#0033A0", "accent": "#FFFFFF"},
"Славия": {"color": "#C8102E", "accent": "#FFFFFF"},
"Карабах": {"color": "#000000", "accent": "#FFFFFF"},
"Кайрат": {"color": "#000000", "accent": "#FFD700"},
"Бенфика": {"color": "#E30613", "accent": "#FFFFFF"},
"Вильярреал": {"color": "#FFE667", "accent": "#00529F"},
"Аякс": {"color": "#D2122E", "accent": "#FFFFFF"},
"Монако": {"color": "#E20E32", "accent": "#FFFFFF"},
"ПСВ": {"color": "#ED1C24", "accent": "#FFFFFF"},
"Спортинг": {"color": "#008053", "accent": "#FFFFFF"},
"Марсель": {"color": "#009DDC", "accent": "#FFFFFF"},
"Боруссия": {"color": "#FDE100", "accent": "#000000"},
"Атлетик": {"color": "#EE2523", "accent": "#FFFFFF"},
"Манчестер Сити": {"color": "#6CABDD", "accent": "#1C2C5B"},
"Манчестер Юнайтед": {"color": "#DA291C", "accent": "#FFE500"},
"Астон Вилла": {"color": "#95BFE5", "accent": "#670E36"},
"Аль-Хилаль": {"color": "#1B458F", "accent": "#FFFFFF"},
"Кристал Пэлас": {"color": "#1B458F", "accent": "#C4122E"},
"Комо": {"color": "#0033A0", "accent": "#FFFFFF"},
}
FREE_AGENT_KEYWORDS = ("свободн", "free")
LOAN_KEYWORDS = ("аренд", "loan")
# ---------------------------------------------------------------------------
# Colour helpers
# ---------------------------------------------------------------------------
def _hex_to_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def _rgb_to_hex(rgb):
return "#{:02X}{:02X}{:02X}".format(*rgb)
def auto_colors(name):
h = hash(name) % 360
r, g, b = colorsys.hsv_to_rgb(h / 360, 0.65, 0.75)
return _rgb_to_hex((int(r * 255), int(g * 255), int(b * 255))), "#FFFFFF"
# ---------------------------------------------------------------------------
# Parse CSV and build JSON
# ---------------------------------------------------------------------------
def parse_csv():
rows = []
with open(CSV_PATH, encoding="utf-8-sig") as f:
reader = csv.DictReader(f, skipinitialspace=True)
for row in reader:
player = (row.get("Player") or "").strip()
if not player:
continue
rows.append({
"player": player,
"club": (row.get("Club") or "").strip(),
"fee": (row.get("Sum") or "").strip(),
"photo": (row.get("Photo") or "").strip(),
"logo": (row.get("Logo") or "").strip(),
})
return rows
def main():
rows = parse_csv()
if not rows:
print("ERROR: no data found in", CSV_PATH)
return
transfers_json = []
warnings = []
for i, r in enumerate(rows):
club_name = r["club"]
if not club_name:
warnings.append(f"Row {i+1}: empty club for player '{r['player']}'")
continue
meta = CLUBS.get(club_name)
if meta:
color = meta["color"]
accent = meta["accent"]
else:
color, accent = auto_colors(club_name)
warnings.append(f"Row {i+1}: unknown club '{club_name}' -> auto colours {color}")
photo = r["photo"] if r["photo"] else "tmp-player.png"
photo_path = os.path.join(BASE_DIR, photo)
if not os.path.exists(photo_path):
warnings.append(f"Row {i+1}: photo '{photo}' not found at project root")
logo = r["logo"] if r["logo"] else ""
logo_path = os.path.join(CLUBS_DIR, logo) if logo else ""
if logo and not os.path.exists(logo_path):
warnings.append(f"Row {i+1}: logo '{logo}' not found in clubs-logo/")
is_free = any(kw in r["fee"].lower() for kw in FREE_AGENT_KEYWORDS)
is_loan = any(kw in r["fee"].lower() for kw in LOAN_KEYWORDS)
price = r["fee"] if r["fee"] else ""
if is_free:
status = "free-agent"
elif is_loan:
status = "loan"
else:
status = "fee"
transfers_json.append({
"id": i + 1,
"playerName": r["player"],
"photo": photo,
"team": {
"name": club_name,
"logo": f"clubs-logo/{logo}" if logo else "",
"color": color,
"accent": accent,
},
"price": price,
"status": status,
})
json_path = os.path.join(DATA_DIR, "transfers.json")
with open(json_path, "w") as f:
json.dump({"transfers": transfers_json}, f, indent=2, ensure_ascii=False)
f.write("\n")
print(f" Data -> {os.path.relpath(json_path, BASE_DIR)}")
if warnings:
print("\n WARNINGS:")
for w in warnings:
print(f" ! {w}")
print(f"\nDone! {len(transfers_json)} transfers written to transfers.json")
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, initial-scale=1">
<title>TRANSFER | Broadcast Graphics</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<style>
@font-face { font-family: 'ONY One'; src: local('ONY One Regular'), url('file:///home/itten/.local/share/fonts/ONYOne-Regular.otf') format('opentype'); font-weight: 400; }
@font-face { font-family: 'ONY One'; src: local('ONY One Medium'), url('file:///home/itten/.local/share/fonts/ONYOne-Medium.otf') format('opentype'); font-weight: 500; }
@font-face { font-family: 'ONY One'; src: local('ONY One Bold'), url('file:///home/itten/.local/share/fonts/ONYOne-Bold.otf') format('opentype'); font-weight: 700; }
</style>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- 1920x1080 broadcast frame (transparent overlay) -->
<div class="frame" id="frame">
<!-- Graphics overlay: 1920x758 -> 641x758 (centered after 5s) -->
<div class="graphics-container" id="graphicsContainer">
<div class="transfer-card" id="transferCard">
<!-- Photo section (source image: 641x758 PNG) -->
<div class="photo-section">
<img class="player-photo" id="playerPhoto" src="" alt="">
<div class="photo-overlay"></div>
</div>
<!-- Info section -->
<div class="info-section">
<!-- Player name -->
<div class="info-mid">
<div class="player-name" id="playerName"></div>
</div>
<!-- Destination club -->
<div class="team-block" id="teamBlock">
<img class="team-logo" id="teamLogo" src="" alt="">
<div class="team-text">
<div class="team-direction" id="teamDirection">КЛУБ</div>
<div class="team-name" id="teamName"></div>
</div>
</div>
<!-- Price -->
<div class="price-badge" id="priceBadge">
<span class="price-label" id="priceLabel">СУММА</span>
<span class="price-value" id="priceValue"></span>
</div>
</div>
</div>
</div>
<!-- Operator panel (dev overlay) -->
<div class="operator-panel" id="operatorPanel">
<div class="op-counter" id="currentTransfer">- / -</div>
<div class="op-controls">
<kbd>&#8592;</kbd> Prev &nbsp; <kbd>&#8594;</kbd> / <kbd>Enter</kbd> Next &nbsp; <kbd>R</kbd> Replay &nbsp; <kbd>H</kbd> Hide &nbsp; <kbd>A</kbd> Autoplay
</div>
</div>
</div>
<script src="js/app.js"></script>
</body>
</html>
+180
View File
@@ -0,0 +1,180 @@
/* ==========================================================================
Transfer Broadcast Graphics - Controller
Data: data/transfers.json (generated from assets/transfers-list.csv)
========================================================================== */
(function () {
"use strict";
var REVEAL_TIME = 5000; // 5 seconds in State 1, then -> compact
var AUTOPLAY_MS = 8000; // auto-advance interval
var transfers = [];
var currentIndex = 0;
var compactTimer = null;
var autoplayTimer = null;
var isHidden = true;
var autoplayOn = false;
var container, card, opCounter;
function $(id) { return document.getElementById(id); }
// --------------------------------------------------------------------
async function init() {
container = $("graphicsContainer");
card = $("transferCard");
opCounter = $("currentTransfer");
try {
var res = await fetch("data/transfers.json");
var data = await res.json();
transfers = data.transfers;
} catch (e) {
console.error("Failed to load transfers.json", e);
opCounter.textContent = "DATA ERROR";
return;
}
updateCounter();
setupKeys();
console.log("Loaded " + transfers.length + " transfers. Press \u2192 or Enter to start.");
}
// --------------------------------------------------------------------
function showTransfer(index) {
if (!transfers.length) return;
var t = transfers[index];
if (!t) return;
container.classList.remove("compact");
// photo
$("playerPhoto").src = t.photo;
// text
$("playerName").textContent = t.playerName;
$("teamName").textContent = t.team.name;
$("teamLogo").src = t.team.logo;
// price / status
var badge = $("priceBadge");
var priceValue = $("priceValue");
var priceLabel = $("priceLabel");
badge.classList.remove("free-agent", "loan");
if (t.status === "free-agent") {
badge.classList.add("free-agent");
priceLabel.textContent = "СТАТУС";
priceValue.textContent = t.price;
} else if (t.status === "loan") {
badge.classList.add("loan");
priceLabel.textContent = "СТАТУС";
priceValue.textContent = t.price;
} else {
priceLabel.textContent = "СУММА";
priceValue.textContent = t.price;
}
// team colour vars
card.style.setProperty("--team-color", t.team.color);
card.style.setProperty("--team-accent", t.team.accent);
// trigger reveal animation (restart)
card.classList.remove("reveal");
void card.offsetWidth;
card.classList.add("reveal");
// show
container.classList.remove("hidden");
container.classList.add("visible");
isHidden = false;
// schedule 5 s compact transition
clearTimeout(compactTimer);
compactTimer = setTimeout(function () {
card.classList.remove("reveal"); // clear animation fill-mode locks so transitions are clean
container.classList.add("compact");
}, REVEAL_TIME);
updateCounter();
}
// --------------------------------------------------------------------
function next() {
if (isHidden) { showTransfer(currentIndex); return; }
currentIndex = (currentIndex + 1) % transfers.length;
showTransfer(currentIndex);
}
function prev() {
if (isHidden) { showTransfer(currentIndex); return; }
currentIndex = (currentIndex - 1 + transfers.length) % transfers.length;
showTransfer(currentIndex);
}
function replay() { showTransfer(currentIndex); }
function toggleHide() {
if (isHidden) {
isHidden = false;
replay();
} else {
isHidden = true;
container.classList.add("hidden");
container.classList.remove("visible");
clearTimeout(compactTimer);
}
}
function toggleAutoplay() {
autoplayOn = !autoplayOn;
if (autoplayOn) {
console.log("Autoplay ON");
if (isHidden) replay();
autoplayTimer = setInterval(next, AUTOPLAY_MS);
} else {
console.log("Autoplay OFF");
clearInterval(autoplayTimer);
}
}
// --------------------------------------------------------------------
function updateCounter() {
if (transfers.length && opCounter)
opCounter.textContent = (currentIndex + 1) + " / " + transfers.length;
}
// --------------------------------------------------------------------
function setupKeys() {
document.addEventListener("keydown", function (e) {
switch (e.key) {
case "ArrowRight":
case "Enter":
e.preventDefault(); next(); break;
case "ArrowLeft":
case "Backspace":
e.preventDefault(); prev(); break;
case "r": case "R": replay(); break;
case "h": case "H": toggleHide(); break;
case "a": case "A": toggleAutoplay(); break;
}
});
}
// Expose for CasparCG / OBS / external control
window.transferGraphics = {
next: next,
prev: prev,
replay: replay,
hide: function () { if (!isHidden) toggleHide(); },
show: function () { if (isHidden) toggleHide(); },
showAt: function (i) { currentIndex = i; showTransfer(i); },
autoplay: toggleAutoplay,
};
// GO
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", init);
else
init();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB