@jjlmoya/utils-streaming 1.19.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +2 -3
- package/src/tests/seo_component_contract.test.ts +22 -0
- package/src/tests/tool_validation.test.ts +17 -17
- package/src/tool/videoBitratePlanner/bibliography.astro +6 -0
- package/src/tool/videoBitratePlanner/bibliography.ts +12 -0
- package/src/tool/videoBitratePlanner/component.astro +122 -0
- package/src/tool/videoBitratePlanner/controller.ts +134 -0
- package/src/tool/videoBitratePlanner/dom-views.ts +77 -0
- package/src/tool/videoBitratePlanner/entry.ts +29 -0
- package/src/tool/videoBitratePlanner/evaluator.ts +19 -0
- package/src/tool/videoBitratePlanner/i18n/de.ts +39 -0
- package/src/tool/videoBitratePlanner/i18n/en.ts +202 -0
- package/src/tool/videoBitratePlanner/i18n/es.ts +39 -0
- package/src/tool/videoBitratePlanner/i18n/fr.ts +39 -0
- package/src/tool/videoBitratePlanner/i18n/id.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/it.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/ja.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/ko.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/nl.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/pl.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/pt.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/ru.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/sv.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/tr.ts +37 -0
- package/src/tool/videoBitratePlanner/i18n/zh.ts +37 -0
- package/src/tool/videoBitratePlanner/index.ts +13 -0
- package/src/tool/videoBitratePlanner/locale-content.ts +72 -0
- package/src/tool/videoBitratePlanner/logic.test.ts +50 -0
- package/src/tool/videoBitratePlanner/logic.ts +158 -0
- package/src/tool/videoBitratePlanner/seo.astro +15 -0
- package/src/tool/videoBitratePlanner/storage.ts +31 -0
- package/src/tool/videoBitratePlanner/ui.ts +50 -0
- package/src/tool/videoBitratePlanner/video-bitrate-storage-planner.css +612 -0
- package/src/tools.ts +2 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { sorteo } from '../tool/sorteo/entry';
|
|
2
2
|
import { tebasCheck } from '../tool/tebasCheck/entry';
|
|
3
|
+
import { videoBitratePlanner } from '../tool/videoBitratePlanner/entry';
|
|
3
4
|
|
|
4
5
|
export const streamingCategory: StreamingCategoryEntry = {
|
|
5
6
|
icon: 'mdi:broadcast',
|
|
6
|
-
tools: [sorteo, tebasCheck],
|
|
7
|
+
tools: [sorteo, tebasCheck, videoBitratePlanner],
|
|
7
8
|
i18n: {
|
|
8
9
|
es: () => import('./i18n/es').then((m) => m.content),
|
|
9
10
|
en: () => import('./i18n/en').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -2,7 +2,10 @@ export { sorteo } from './tool/sorteo/entry';
|
|
|
2
2
|
export type { SorteoLocaleContent } from './tool/sorteo/entry';
|
|
3
3
|
export { tebasCheck } from './tool/tebasCheck/entry';
|
|
4
4
|
export type { TebasCheckLocaleContent } from './tool/tebasCheck/entry';
|
|
5
|
+
export { videoBitratePlanner } from './tool/videoBitratePlanner/entry';
|
|
6
|
+
export type { VideoBitratePlannerLocaleContent } from './tool/videoBitratePlanner/entry';
|
|
5
7
|
export { streamingCategory } from './category';
|
|
6
8
|
import { sorteo } from './tool/sorteo/entry';
|
|
7
9
|
import { tebasCheck } from './tool/tebasCheck/entry';
|
|
8
|
-
|
|
10
|
+
import { videoBitratePlanner } from './tool/videoBitratePlanner/entry';
|
|
11
|
+
export const ALL_ENTRIES = [sorteo, tebasCheck, videoBitratePlanner];
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const toolSeoFiles = [
|
|
6
|
+
'sorteo/seo.astro',
|
|
7
|
+
'tebasCheck/seo.astro',
|
|
8
|
+
'videoBitratePlanner/seo.astro',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('SEO component contract', () => {
|
|
12
|
+
for (const relativePath of toolSeoFiles) {
|
|
13
|
+
it(`${relativePath} loads localized SEO sections from locale`, async () => {
|
|
14
|
+
const source = await readFile(resolve(__dirname, '../tool', relativePath), 'utf8');
|
|
15
|
+
|
|
16
|
+
expect(source).toMatch(/locale\?: KnownLocale/);
|
|
17
|
+
expect(source).toMatch(/locale = 'es'/);
|
|
18
|
+
expect(source).toMatch(/entry\.i18n\[locale\]\?\.\(\)/);
|
|
19
|
+
expect(source).toMatch(/sections: content\.seo/);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { ALL_TOOLS } from '../tools';
|
|
3
|
-
import { streamingCategory } from '../data';
|
|
4
|
-
|
|
5
|
-
describe('Tool Validation Suite', () => {
|
|
6
|
-
describe('Library Registration', () => {
|
|
7
|
-
it('should have at least 2 tools in ALL_TOOLS', () => {
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('streamingCategory should be defined', () => {
|
|
12
|
-
expect(streamingCategory).toBeDefined();
|
|
13
|
-
expect(streamingCategory.i18n).toBeDefined();
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
import { streamingCategory } from '../data';
|
|
4
|
+
|
|
5
|
+
describe('Tool Validation Suite', () => {
|
|
6
|
+
describe('Library Registration', () => {
|
|
7
|
+
it('should have at least 2 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(3);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('streamingCategory should be defined', () => {
|
|
12
|
+
expect(streamingCategory).toBeDefined();
|
|
13
|
+
expect(streamingCategory.i18n).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'YouTube recommended live encoder settings',
|
|
6
|
+
url: 'https://support.google.com/youtube/answer/2853702?hl=en',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'YouTube recommended upload encoding settings',
|
|
10
|
+
url: 'https://support.google.com/youtube/answer/1722171?hl=en',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { VideoBitratePlannerUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: VideoBitratePlannerUI;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
const uiJson = JSON.stringify(ui).replace(/</g, '\\u003c');
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="bitrate-planner" data-video-bitrate-planner data-resolution="1080p" data-fps="60" data-codec="h264">
|
|
13
|
+
<script is:inline type="application/json" data-planner-ui set:html={uiJson}></script>
|
|
14
|
+
<form class="planner-layout" data-planner-form>
|
|
15
|
+
<div class="planner-controls">
|
|
16
|
+
<div class="preset-line">
|
|
17
|
+
<span class="control-caption">{ui.presetLabel}</span>
|
|
18
|
+
<button type="button" class="preset-button" data-preset="fast">{ui.presetFast}</button>
|
|
19
|
+
<button type="button" class="preset-button is-active" data-preset="upload">{ui.presetUpload}</button>
|
|
20
|
+
<button type="button" class="preset-button" data-preset="archive">{ui.presetArchive}</button>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<fieldset class="control-fieldset">
|
|
24
|
+
<legend>{ui.resolutionLabel}</legend>
|
|
25
|
+
<div class="choice-row">
|
|
26
|
+
<button type="button" class="choice-button" data-resolution="720p" aria-pressed="false">720p</button>
|
|
27
|
+
<button type="button" class="choice-button is-active" data-resolution="1080p" aria-pressed="true">1080p</button>
|
|
28
|
+
<button type="button" class="choice-button" data-resolution="1440p" aria-pressed="false">1440p</button>
|
|
29
|
+
<button type="button" class="choice-button" data-resolution="2160p" aria-pressed="false">4K</button>
|
|
30
|
+
</div>
|
|
31
|
+
</fieldset>
|
|
32
|
+
|
|
33
|
+
<fieldset class="control-fieldset">
|
|
34
|
+
<legend>{ui.frameRateLabel}</legend>
|
|
35
|
+
<div class="choice-row">
|
|
36
|
+
<button type="button" class="choice-button" data-fps="30" aria-pressed="false">30 fps</button>
|
|
37
|
+
<button type="button" class="choice-button is-active" data-fps="60" aria-pressed="true">60 fps</button>
|
|
38
|
+
<button type="button" class="choice-button" data-fps="120" aria-pressed="false">120 fps</button>
|
|
39
|
+
</div>
|
|
40
|
+
</fieldset>
|
|
41
|
+
|
|
42
|
+
<fieldset class="control-fieldset">
|
|
43
|
+
<legend>{ui.codecLabel}</legend>
|
|
44
|
+
<div class="choice-row">
|
|
45
|
+
<button type="button" class="choice-button" data-codec="h264" aria-pressed="true">{ui.h264}</button>
|
|
46
|
+
<button type="button" class="choice-button" data-codec="h265" aria-pressed="false">{ui.h265}</button>
|
|
47
|
+
<button type="button" class="choice-button" data-codec="av1" aria-pressed="false">{ui.av1}</button>
|
|
48
|
+
</div>
|
|
49
|
+
<p class="control-note">{ui.codecNote}</p>
|
|
50
|
+
</fieldset>
|
|
51
|
+
|
|
52
|
+
<div class="number-grid">
|
|
53
|
+
<label class="number-control">
|
|
54
|
+
<span>{ui.bitrateLabel}</span>
|
|
55
|
+
<span class="number-input-wrap"><input name="bitrate" type="number" inputmode="decimal" min="0.1" max="500" step="0.1" value="8" aria-label={ui.bitrateLabel} /><b>Mbps</b></span>
|
|
56
|
+
</label>
|
|
57
|
+
<label class="number-control">
|
|
58
|
+
<span>{ui.durationLabel}</span>
|
|
59
|
+
<span class="number-input-wrap"><input name="duration" type="number" inputmode="numeric" min="1" max="10080" step="1" value="60" aria-label={ui.durationLabel} /><b>{ui.minutesLabel}</b></span>
|
|
60
|
+
</label>
|
|
61
|
+
<label class="number-control">
|
|
62
|
+
<span>{ui.copiesLabel}</span>
|
|
63
|
+
<span class="number-input-wrap"><input name="copies" type="number" inputmode="numeric" min="1" max="99" step="1" value="1" aria-label={ui.copiesLabel} /><b>{ui.copiesShort}</b></span>
|
|
64
|
+
</label>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="control-footer">
|
|
67
|
+
<p class="local-note">{ui.localNote}</p>
|
|
68
|
+
<button type="button" class="reset-button" data-reset>{ui.reset}</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<section class="planner-scene" aria-live="polite" aria-label={ui.sceneLabel}>
|
|
73
|
+
<div class="scene-heading">
|
|
74
|
+
<span class="scene-label">{ui.sceneLabel}</span>
|
|
75
|
+
<span class="quality-badge" data-quality-badge></span>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="signal-world">
|
|
78
|
+
<div class="signal-source">
|
|
79
|
+
<div class="source-screen"><div class="screen-grid"><i></i><i></i><i></i><i></i></div><span data-resolution-value>1080p</span></div>
|
|
80
|
+
<span class="world-label">{ui.signalSource}</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="signal-bridge"><span class="bridge-line"></span><span class="codec-gate" data-codec-value>H264</span><span class="bridge-line"></span></div>
|
|
83
|
+
<div class="signal-reel">
|
|
84
|
+
<div class="reel-disc"><span class="reel-hole"></span><span class="reel-groove"></span><span class="reel-groove reel-groove-inner"></span></div>
|
|
85
|
+
<span class="world-label">{ui.storageReel}</span>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="signal-frames" data-signal-frames aria-hidden="true"></div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="scene-caption"><span>{ui.codecGate}</span><strong data-fps-value>60 fps</strong></div>
|
|
90
|
+
<div class="main-result">
|
|
91
|
+
<span>{ui.storageEstimate}</span>
|
|
92
|
+
<strong data-total-storage>3.6 GB</strong>
|
|
93
|
+
<small data-storage-caption>{ui.allCopies}: 3.6 GB {ui.copiesShort}</small>
|
|
94
|
+
</div>
|
|
95
|
+
<p class="quality-note" data-quality-note></p>
|
|
96
|
+
<div class="badge-line"><span class="capacity-badge" data-capacity-badge></span><span class="capacity-note">{ui.capacityNote}</span></div>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<div class="planner-lower">
|
|
100
|
+
<div class="stat-strip">
|
|
101
|
+
<div><span>{ui.perCopy}</span><strong data-per-copy>3.6 GB</strong></div>
|
|
102
|
+
<div><span>{ui.perHour}</span><strong data-per-hour>60 GB</strong></div>
|
|
103
|
+
<div><span>{ui.frameTime}</span><strong data-frame-time>16.67 ms</strong></div>
|
|
104
|
+
<div><span>{ui.dataPerFrame}</span><strong data-data-per-frame>16.67 KB</strong></div>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="comparison-panel">
|
|
107
|
+
<div class="comparison-heading"><span>{ui.comparisonLabel}</span><span data-resolution-value>1080p</span></div>
|
|
108
|
+
<div data-comparison></div>
|
|
109
|
+
</div>
|
|
110
|
+
<details class="assumption-panel">
|
|
111
|
+
<summary>{ui.assumptionTitle}</summary>
|
|
112
|
+
<p>{ui.assumptionText}</p>
|
|
113
|
+
<p>{ui.warningText}</p>
|
|
114
|
+
</details>
|
|
115
|
+
</div>
|
|
116
|
+
</form>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<script>
|
|
120
|
+
import { mountVideoBitratePlanner } from './controller';
|
|
121
|
+
mountVideoBitratePlanner();
|
|
122
|
+
</script>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { calculatePlan, type PlannerInput, type VideoCodec } from './logic';
|
|
2
|
+
import { clearPlannerInput, loadPlannerInput, savePlannerInput } from './storage';
|
|
3
|
+
import { renderPlanner } from './dom-views';
|
|
4
|
+
import type { VideoBitratePlannerUI } from './ui';
|
|
5
|
+
|
|
6
|
+
const defaults: PlannerInput = {
|
|
7
|
+
resolutionId: '1080p',
|
|
8
|
+
fps: 60,
|
|
9
|
+
codec: 'h264',
|
|
10
|
+
bitrateMbps: 8,
|
|
11
|
+
durationMinutes: 60,
|
|
12
|
+
copies: 1,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const labels = {
|
|
16
|
+
quality: {
|
|
17
|
+
aggressive: 'Aggressive compression',
|
|
18
|
+
lean: 'Lean and light',
|
|
19
|
+
balanced: 'Balanced signal',
|
|
20
|
+
strong: 'Strong detail',
|
|
21
|
+
excellent: 'Excellent headroom',
|
|
22
|
+
},
|
|
23
|
+
capacity: {
|
|
24
|
+
light: 'Light storage footprint',
|
|
25
|
+
medium: 'Medium storage footprint',
|
|
26
|
+
heavy: 'Heavy storage footprint',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function getInput(root: HTMLElement): PlannerInput {
|
|
31
|
+
const mode = getMode(root);
|
|
32
|
+
return {
|
|
33
|
+
...mode,
|
|
34
|
+
bitrateMbps: getNumberInput(root, '[name="bitrate"]', defaults.bitrateMbps),
|
|
35
|
+
durationMinutes: getNumberInput(root, '[name="duration"]', defaults.durationMinutes),
|
|
36
|
+
copies: getNumberInput(root, '[name="copies"]', defaults.copies),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getMode(root: HTMLElement): Pick<PlannerInput, 'resolutionId' | 'fps' | 'codec'> {
|
|
41
|
+
return {
|
|
42
|
+
resolutionId: root.dataset.resolution || defaults.resolutionId,
|
|
43
|
+
fps: Number(root.dataset.fps || defaults.fps),
|
|
44
|
+
codec: (root.dataset.codec || defaults.codec) as VideoCodec,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getNumberInput(root: HTMLElement, selector: string, fallback: number): number {
|
|
49
|
+
const input = root.querySelector<HTMLInputElement>(selector);
|
|
50
|
+
return Number(input?.value || fallback);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setInput(root: HTMLElement, input: PlannerInput): void {
|
|
54
|
+
root.dataset.resolution = input.resolutionId;
|
|
55
|
+
root.dataset.fps = String(input.fps);
|
|
56
|
+
root.dataset.codec = input.codec;
|
|
57
|
+
setValue(root, '[name="bitrate"]', input.bitrateMbps.toString());
|
|
58
|
+
setValue(root, '[name="duration"]', input.durationMinutes.toString());
|
|
59
|
+
setValue(root, '[name="copies"]', input.copies.toString());
|
|
60
|
+
syncActiveButtons(root);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setValue(root: HTMLElement, selector: string, value: string): void {
|
|
64
|
+
const input = root.querySelector<HTMLInputElement>(selector);
|
|
65
|
+
if (input) input.value = value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function syncActiveButtons(root: HTMLElement): void {
|
|
69
|
+
root.querySelectorAll<HTMLButtonElement>('[data-resolution], [data-fps], [data-codec]').forEach((button) => {
|
|
70
|
+
const key = isActiveButton(root, button);
|
|
71
|
+
button.classList.toggle('is-active', Boolean(key));
|
|
72
|
+
button.setAttribute('aria-pressed', String(Boolean(key)));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isActiveButton(root: HTMLElement, button: HTMLButtonElement): boolean {
|
|
77
|
+
if (button.dataset.resolution) return root.dataset.resolution === button.dataset.resolution;
|
|
78
|
+
if (button.dataset.fps) return root.dataset.fps === button.dataset.fps;
|
|
79
|
+
return root.dataset.codec === button.dataset.codec;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function update(root: HTMLElement, ui: VideoBitratePlannerUI): void {
|
|
83
|
+
const input = getInput(root);
|
|
84
|
+
const result = calculatePlan(input);
|
|
85
|
+
renderPlanner(root, result, ui, labels);
|
|
86
|
+
savePlannerInput(result.input);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function applyPreset(root: HTMLElement, preset: string): void {
|
|
90
|
+
const presets: Record<string, PlannerInput> = {
|
|
91
|
+
fast: { ...defaults, resolutionId: '720p', fps: 30, bitrateMbps: 4 },
|
|
92
|
+
upload: { ...defaults, resolutionId: '1080p', fps: 60, bitrateMbps: 8 },
|
|
93
|
+
archive: { ...defaults, resolutionId: '2160p', fps: 30, codec: 'av1', bitrateMbps: 35 },
|
|
94
|
+
};
|
|
95
|
+
const selected = presets[preset];
|
|
96
|
+
if (selected) setInput(root, selected);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleClick(event: Event, root: HTMLElement, ui: VideoBitratePlannerUI): void {
|
|
100
|
+
const target = event.target as HTMLElement;
|
|
101
|
+
const button = target.closest<HTMLButtonElement>('button');
|
|
102
|
+
if (!button) return;
|
|
103
|
+
const preset = button.dataset.preset;
|
|
104
|
+
if (preset) applyPreset(root, preset);
|
|
105
|
+
if (button.dataset.resolution) root.dataset.resolution = button.dataset.resolution;
|
|
106
|
+
if (button.dataset.fps) root.dataset.fps = button.dataset.fps;
|
|
107
|
+
if (button.dataset.codec) root.dataset.codec = button.dataset.codec as VideoCodec;
|
|
108
|
+
if (button.dataset.reset !== undefined) {
|
|
109
|
+
clearPlannerInput();
|
|
110
|
+
setInput(root, defaults);
|
|
111
|
+
}
|
|
112
|
+
syncActiveButtons(root);
|
|
113
|
+
update(root, ui);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function start(root: HTMLElement): void {
|
|
117
|
+
const uiScript = root.querySelector<HTMLScriptElement>('[data-planner-ui]');
|
|
118
|
+
if (!uiScript) return;
|
|
119
|
+
const ui = JSON.parse(uiScript.textContent ?? '{}') as VideoBitratePlannerUI;
|
|
120
|
+
const saved = loadPlannerInput(defaults);
|
|
121
|
+
setInput(root, saved);
|
|
122
|
+
root.addEventListener('click', (event) => handleClick(event, root, ui));
|
|
123
|
+
root.addEventListener('input', () => update(root, ui));
|
|
124
|
+
root.addEventListener('change', () => update(root, ui));
|
|
125
|
+
update(root, ui);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function mountVideoBitratePlanner(): void {
|
|
129
|
+
document.querySelectorAll<HTMLElement>('[data-video-bitrate-planner]').forEach((root) => {
|
|
130
|
+
if (root.dataset.mounted) return;
|
|
131
|
+
root.dataset.mounted = 'true';
|
|
132
|
+
start(root);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { VideoBitratePlannerUI } from './ui';
|
|
2
|
+
import type { PlannerResult } from './logic';
|
|
3
|
+
import { getCapacityLabel, getQualityLabel, type EvaluatorLabels } from './evaluator';
|
|
4
|
+
|
|
5
|
+
function number(value: number, digits = 2): string {
|
|
6
|
+
return new Intl.NumberFormat('en-US', { maximumFractionDigits: digits }).format(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function storage(value: number): string {
|
|
10
|
+
if (value >= 1000) return `${number(value / 1000)} TB`;
|
|
11
|
+
return `${number(value)} GB`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function qualityTone(result: PlannerResult): string {
|
|
15
|
+
return result.quality.tone;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderFrames(root: HTMLElement, result: PlannerResult): void {
|
|
19
|
+
const frameCount = Math.min(18, Math.max(6, Math.round(result.input.bitrateMbps * 1.4)));
|
|
20
|
+
const frames = Array.from({ length: frameCount }, (_, index) => {
|
|
21
|
+
const active = index % 4 === 0 ? ' frame-accent' : '';
|
|
22
|
+
return `<span class="signal-frame${active}" style="--frame-delay:${index * 55}ms"></span>`;
|
|
23
|
+
}).join('');
|
|
24
|
+
const target = root.querySelector<HTMLElement>('[data-signal-frames]');
|
|
25
|
+
if (target) target.innerHTML = frames;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderResultText(root: HTMLElement, result: PlannerResult, ui: VideoBitratePlannerUI, labels: EvaluatorLabels): void {
|
|
29
|
+
const quality = root.querySelector<HTMLElement>('[data-quality-badge]');
|
|
30
|
+
const qualityNote = root.querySelector<HTMLElement>('[data-quality-note]');
|
|
31
|
+
const capacity = root.querySelector<HTMLElement>('[data-capacity-badge]');
|
|
32
|
+
if (quality) {
|
|
33
|
+
quality.textContent = getQualityLabel(result, labels);
|
|
34
|
+
quality.dataset.tone = qualityTone(result);
|
|
35
|
+
}
|
|
36
|
+
if (qualityNote) qualityNote.textContent = `${ui.qualityEstimate}: ${result.quality.guidance}`;
|
|
37
|
+
if (capacity) {
|
|
38
|
+
capacity.textContent = getCapacityLabel(result, labels);
|
|
39
|
+
capacity.dataset.tone = result.capacityTone;
|
|
40
|
+
}
|
|
41
|
+
setText(root, '[data-total-storage]', storage(result.allCopiesGb));
|
|
42
|
+
setText(root, '[data-per-copy]', storage(result.perCopyGb));
|
|
43
|
+
setText(root, '[data-per-hour]', storage(result.bitratePerHourGb));
|
|
44
|
+
setText(root, '[data-frame-time]', `${number(result.frameTimeMs)} ms`);
|
|
45
|
+
setText(root, '[data-data-per-frame]', `${number(result.dataPerFrameKb)} KB`);
|
|
46
|
+
setText(root, '[data-storage-caption]', `${ui.allCopies}: ${storage(result.allCopiesGb)} total`);
|
|
47
|
+
setText(root, '[data-codec-value]', result.input.codec.toUpperCase());
|
|
48
|
+
setText(root, '[data-resolution-value]', result.resolution.label);
|
|
49
|
+
setText(root, '[data-fps-value]', `${result.input.fps} fps`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setText(root: HTMLElement, selector: string, value: string): void {
|
|
53
|
+
const target = root.querySelector<HTMLElement>(selector);
|
|
54
|
+
if (target) target.textContent = value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderComparison(root: HTMLElement, result: PlannerResult, ui: VideoBitratePlannerUI): void {
|
|
58
|
+
const target = root.querySelector<HTMLElement>('[data-comparison]');
|
|
59
|
+
if (!target) return;
|
|
60
|
+
const names: Record<string, string> = { lean: ui.lean, balanced: ui.balanced, crisp: ui.crisp };
|
|
61
|
+
target.innerHTML = result.comparison.map((row) => `
|
|
62
|
+
<div class="comparison-row" data-tone="${row.tone}">
|
|
63
|
+
<span class="comparison-mark"></span>
|
|
64
|
+
<span class="comparison-name">${names[row.label]}</span>
|
|
65
|
+
<strong>${number(row.bitrateMbps)} Mbps</strong>
|
|
66
|
+
<span>${storage(row.storageGb)}</span>
|
|
67
|
+
<span class="comparison-quality">${row.quality}</span>
|
|
68
|
+
</div>`).join('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderPlanner(root: HTMLElement, result: PlannerResult, ui: VideoBitratePlannerUI, labels: EvaluatorLabels): void {
|
|
72
|
+
renderFrames(root, result);
|
|
73
|
+
renderResultText(root, result, ui, labels);
|
|
74
|
+
renderComparison(root, result, ui);
|
|
75
|
+
root.dataset.qualityTone = result.quality.tone;
|
|
76
|
+
root.dataset.capacityTone = result.capacityTone;
|
|
77
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { StreamingToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { VideoBitratePlannerUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type VideoBitratePlannerLocaleContent = ToolLocaleContent<VideoBitratePlannerUI>;
|
|
5
|
+
|
|
6
|
+
export const videoBitratePlanner: StreamingToolEntry<VideoBitratePlannerUI> = {
|
|
7
|
+
id: 'video-bitrate-planner',
|
|
8
|
+
icons: {
|
|
9
|
+
bg: 'mdi:video-outline',
|
|
10
|
+
fg: 'mdi:harddisk',
|
|
11
|
+
},
|
|
12
|
+
i18n: {
|
|
13
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
14
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
15
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
16
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
17
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
18
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
19
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
20
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
21
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
22
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
23
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
24
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
25
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
26
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
27
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PlannerResult, QualityResult } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface EvaluatorLabels {
|
|
4
|
+
quality: Record<QualityResult['tone'], string>;
|
|
5
|
+
capacity: Record<PlannerResult['capacityTone'], string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getQualityLabel(result: PlannerResult, labels: EvaluatorLabels): string {
|
|
9
|
+
return labels.quality[result.quality.tone];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getCapacityLabel(result: PlannerResult, labels: EvaluatorLabels): string {
|
|
13
|
+
return labels.capacity[result.capacityTone];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getNetworkNote(result: PlannerResult): string {
|
|
17
|
+
const headroom = Math.max(1, result.input.bitrateMbps * 1.2);
|
|
18
|
+
return `Plan at least ${headroom.toFixed(1)} Mbps of upload headroom for a ${result.input.bitrateMbps.toFixed(1)} Mbps stream.`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createLocalizedContent } from '../locale-content';
|
|
2
|
+
|
|
3
|
+
export const content = createLocalizedContent({
|
|
4
|
+
language: 'de',
|
|
5
|
+
slug: 'video-bitrate-speicherplaner',
|
|
6
|
+
title: 'Video Bitrate und Speicherplaner',
|
|
7
|
+
description: 'Schätze Videospeicher, Bildzeiten und praktische Bitraten für Streaming oder Aufnahmen.',
|
|
8
|
+
ui: {
|
|
9
|
+
presetLabel: 'Mit einer Szene starten', presetFast: 'Schneller Webstream', presetUpload: 'Alltäglicher Livestream', presetArchive: '4K Archiv',
|
|
10
|
+
resolutionLabel: 'Auflösung', frameRateLabel: 'Bildrate', codecLabel: 'Codec', bitrateLabel: 'Video Bitrate', durationLabel: 'Sitzungsdauer', copiesLabel: 'Gespeicherte Kopien', minutesLabel: 'Minuten', copiesShort: 'Kopien',
|
|
11
|
+
h264: 'H.264', h265: 'H.265', av1: 'AV1', codecNote: 'Die Codec Effizienz verändert die Qualitätsbewertung, nicht die Speicherrechnung.', sceneLabel: 'Signal zum Speicher', signalSource: 'Bild', codecGate: 'Kodierung', storageReel: 'Speicher', qualityEstimate: 'Qualitätsbewertung', storageEstimate: 'Geschätzter Speicher', perCopy: 'Eine Kopie', allCopies: 'Alle Kopien', perHour: 'Pro Stunde', frameTime: 'Bildzeit', dataPerFrame: 'Daten pro Bild', comparisonLabel: 'Speichervergleich', lean: 'Sparsam', balanced: 'Ausgewogen', crisp: 'Scharf', qualityLean: 'Sparsam und leicht', qualityBalanced: 'Ausgewogenes Signal', qualityStrong: 'Viele Details', qualityExcellent: 'Sehr viel Spielraum', qualityAggressive: 'Starke Kompression', qualityGuidance: 'Eine visuelle Schätzung zum Vergleich der Einstellungen.', capacityLight: 'Geringer Speicherbedarf', capacityMedium: 'Mittlerer Speicherbedarf', capacityHeavy: 'Hoher Speicherbedarf', capacityNote: 'Der Speicherstatus basiert auf allen oben angezeigten Kopien.', reset: 'Werte zurücksetzen', localNote: 'Läuft lokal im Browser. Nichts wird hochgeladen.', assumptionTitle: 'Annahmen lesen', assumptionText: 'Der Speicher nutzt dezimale Gigabyte und die eingegebene Videobitrate. Audio, Container-Overhead, Spitzen variabler Bitraten und Dateisystem-Padding werden nicht addiert.', warningText: 'Die Qualitätsstufen sind Planungswerte. Bewegung, Körnung, Keyframes, Encoder-Presets, Plattform-Transkodierung und Netzwerkreserven verändern das echte Ergebnis.', readyText: 'Ändere einen Wert, um das Signal neu zu zeichnen.', calculateAria: 'Videoplan aktualisieren',
|
|
12
|
+
},
|
|
13
|
+
faq: [
|
|
14
|
+
{ question: 'Lädt oder untersucht dieser Planer mein Video?', answer: 'Nein. Er verwendet nur die Werte, die du im Browser eingibst. Es werden keine Dateien hochgeladen, keine Kamera untersucht und kein Streamingdienst abgefragt.' },
|
|
15
|
+
{ question: 'Wie wird der Speicher berechnet?', answer: 'Die Bitrate wird mit der Dauer multipliziert und durch acht geteilt, um Bits in Bytes umzuwandeln. Das Ergebnis nutzt dezimale Gigabyte und wird mit der Anzahl der Kopien multipliziert.' },
|
|
16
|
+
{ question: 'Was bedeutet die Qualitätsbewertung?', answer: 'Sie ist eine Faustregel aus Pixeln, Bildern pro Sekunde, Bitrate und einem groben Codec Faktor. Sie garantiert keine Bildqualität, da Bewegung, Körnung und Encoder Einstellungen ebenfalls zählen.' },
|
|
17
|
+
{ question: 'Warum ändert sich dieselbe Bitrate bei anderer Auflösung oder Bildrate?', answer: 'Eine höhere Auflösung beschreibt mehr Pixel und eine höhere Bildrate sendet mehr Bilder pro Sekunde. Dadurch konkurriert mehr Bildinformation um dieselbe Bitrate.' },
|
|
18
|
+
{ question: 'Kann ich das Ergebnis als Plattformvorgabe verwenden?', answer: 'Nutze es für Kapazitätsplanung und Vergleiche. Plattformvorgaben ändern sich, deshalb prüfe die aktuelle Encoder Anleitung des Ziels und lasse beim Livestream Upload Reserve.' },
|
|
19
|
+
],
|
|
20
|
+
howTo: [
|
|
21
|
+
{ name: 'Bildformat wählen', text: 'Wähle die Auflösung und Bildrate, die zu deinem geplanten Stream oder deiner Aufnahme passen.' },
|
|
22
|
+
{ name: 'Kodierung festlegen', text: 'Wähle den Codec und gib die Videobitrate in Megabit pro Sekunde ein. Ein Preset ist ein guter Startpunkt.' },
|
|
23
|
+
{ name: 'Sitzung beschreiben', text: 'Gib die Dauer in Minuten und die Anzahl der Kopien ein, die du behalten, bearbeiten oder ausliefern möchtest.' },
|
|
24
|
+
{ name: 'Abwägung lesen', text: 'Vergleiche sparsame, ausgewogene und scharfe Stufen, um den Speicherbedarf vor der Aufnahme zu prüfen.' },
|
|
25
|
+
],
|
|
26
|
+
seo: [
|
|
27
|
+
{ type: 'title', text: 'Videospeicher vor dem Stream oder der Aufnahme schätzen', level: 2 },
|
|
28
|
+
{ type: 'paragraph', html: 'Ein Video Bitraten Rechner hilft, wenn eine Aufnahme einen realistischen Speicherplan braucht. Gib Bitrate, Dauer und Kopien ein und vergleiche anschließend drei Signalstufen für dasselbe Bildformat.' },
|
|
29
|
+
{ type: 'title', text: 'Was der Planer berechnet', level: 3 },
|
|
30
|
+
{ type: 'list', items: ['<strong>Speicher:</strong> Bitrate mal Zeit, von Bits in dezimale Gigabyte umgerechnet und mit den Kopien multipliziert.', '<strong>Bildzeit:</strong> Millisekunden pro Bild bei der gewählten FPS Zahl sowie eine Schätzung der Daten pro Bild.', '<strong>Qualitätsbewertung:</strong> ein Pixel pro Bild Vergleich mit einem groben Codec Faktor.'] },
|
|
31
|
+
{ type: 'title', text: 'Wie Auflösung und FPS die Abwägung ändern', level: 3 },
|
|
32
|
+
{ type: 'paragraph', html: 'Eine höhere Auflösung erhöht die Pixelzahl pro Bild. Eine höhere Bildrate erhöht die Bildzahl pro Sekunde. Bleibt die Bitrate gleich, erhält jedes Bild weniger Daten und die Kompression wird anspruchsvoller.' },
|
|
33
|
+
{ type: 'tip', title: 'Reserve für Livestreams lassen', html: 'Behandle die Videobitrate nicht als gesamte Leitungskapazität. Lass Platz für Audio, Protokoll-Overhead und Netzschwankungen und teste eine Szene mit ähnlicher Bewegung.' },
|
|
34
|
+
{ type: 'title', text: 'Für die letzte Einstellung Plattformvorgaben nutzen', level: 3 },
|
|
35
|
+
{ type: 'paragraph', html: 'Dieser Planer bleibt plattformneutral. YouTube veröffentlicht Bitratenbereiche nach Auflösung und Bildrate. Nutze die aktuellen Regeln deines Ziels, um das hier modellierte Szenario zu prüfen.' },
|
|
36
|
+
{ type: 'title', text: 'Warum das Speicherergebnis eine Schätzung ist', level: 3 },
|
|
37
|
+
{ type: 'paragraph', html: 'Eine nominelle Bitrate beschreibt nicht jedes Byte einer fertigen Datei. Variable Bitrate, Audio, Containerdaten, Keyframes, Transkodierung und Dateieinheiten können die Endgröße verändern.' },
|
|
38
|
+
],
|
|
39
|
+
});
|