[feat] Add Dreamverse frontend media and E2E coverage
Apply Dreamverse monorepo changes for stack slice 8/13 from the source branch.
Source-Branch: will/dreamverse-monorepo
Source-SHA: 03d3e61df6
Dreamverse-Stack: 8/13
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Matthew Noto <99706358+RandNMR73@users.noreply.github.com>
Co-authored-by: XOR-op <17672363+XOR-op@users.noreply.github.com>
Co-authored-by: Zhang Peiyuan <42993249+jzhang38@users.noreply.github.com>
This commit is contained in:
co-authored by
SolitaryThinker
Matthew Noto
XOR-op
Zhang Peiyuan
parent
bdad051a9a
commit
2eedfa0449
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke layer 1: the Dreamverse Python server is reachable and the
|
||||
* Next.js rewrite proxy at /healthz forwards to it. This runs before
|
||||
* any UI interaction so a UI failure has a known-good baseline.
|
||||
*/
|
||||
test.describe('backend health', () => {
|
||||
test('healthz returns ok via the next.js rewrite', async ({ request }) => {
|
||||
const response = await request.get('/healthz');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.service).toBe('ltx2-streaming-backend');
|
||||
});
|
||||
|
||||
test('readyz reports gpu pool state', async ({ request }) => {
|
||||
// /readyz returns 200 once the GPU pool is warm; 503 with a
|
||||
// {detail: ...} body otherwise. Either is a valid integration
|
||||
// signal — we just want to confirm the route is wired and the
|
||||
// payload shape is what the FE expects.
|
||||
const response = await request.get('/readyz');
|
||||
expect([200, 503]).toContain(response.status());
|
||||
const text = await response.text();
|
||||
if (response.status() === 503) {
|
||||
// Frontend reads .detail to render the "wait for warmup" banner.
|
||||
expect(text).toMatch(/detail/);
|
||||
}
|
||||
});
|
||||
|
||||
test('status endpoint exposes gpu pool snapshot', async ({ request }) => {
|
||||
const response = await request.get('/status');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body).toHaveProperty('total_gpus');
|
||||
expect(body).toHaveProperty('gpu_status');
|
||||
expect(typeof body.total_gpus).toBe('number');
|
||||
});
|
||||
|
||||
test('prompt-system-config exposes the operator-tunable prompts', async ({ request }) => {
|
||||
const response = await request.get('/prompt-system-config');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
// page.tsx reads these to seed the composer when the user opens
|
||||
// the prompt-edit drawer; missing keys = silent UI breakage.
|
||||
expect(body).toHaveProperty('next_segment_system_prompt');
|
||||
expect(body).toHaveProperty('auto_extension_system_prompt');
|
||||
expect(body).toHaveProperty('rewrite_window_system_prompt');
|
||||
});
|
||||
|
||||
test('curated presets endpoint serves a non-empty list (devtools only)', async ({ request }) => {
|
||||
const response = await request.get('/curated-presets');
|
||||
test.skip(
|
||||
response.status() === 404,
|
||||
'Skipping curated-presets check: backend was not booted with ' +
|
||||
'FASTVIDEO_ENABLE_DEVTOOLS=1, so the devtools-only route is not ' +
|
||||
'mounted. Re-run with that env var to exercise it.',
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(Array.isArray(body.presets)).toBeTruthy();
|
||||
expect(body.presets.length).toBeGreaterThan(0);
|
||||
for (const preset of body.presets) {
|
||||
expect(preset).toHaveProperty('id');
|
||||
expect(preset).toHaveProperty('label');
|
||||
expect(Array.isArray(preset.segment_prompts)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke layer 2: the Next.js shell renders without crashing and shows
|
||||
* the expected GPU-warmup banner when the backend's GPU pool isn't
|
||||
* ready yet. This catches integration breakage between the frontend
|
||||
* (running against the public FastVideo backend) and the readyz
|
||||
* payload shape.
|
||||
*/
|
||||
test.describe('frontend shell', () => {
|
||||
test('main page loads and exposes the FastVideo brand chip', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The TopStatusBar element is keyed off aria-label="FastVideo"
|
||||
// and is rendered before any session interaction is possible.
|
||||
await expect(page.getByRole('img', { name: 'FastVideo' })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('composer hydrates with curated preset cards', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// The Continuation prompt textarea + Generate button render once
|
||||
// the FE has hydrated against the public-FastVideo-backed
|
||||
// dreamverse-server. Their presence proves the integration handshake
|
||||
// (CORS, /curated-presets, /prompt-system-config) completed.
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const generate = page.getByRole('button', { name: /^generate$/i });
|
||||
await expect(generate).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Curated presets render as buttons; verify at least one is
|
||||
// available — that's the only way the user can populate the
|
||||
// Continuation textarea in the default composer.
|
||||
const presetCard = page.getByRole('button', {
|
||||
name: /LEGO Stormtroopers|Clay Stop-Motion|Boy & Dog|School Prank|Gamer Gets Banned|Small Town Oil Strike|Grandpa's Wing Costume/i,
|
||||
}).first();
|
||||
await expect(presetCard).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { test, expect, type WebSocket as PWWebSocket } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Long-running e2e: drive a real two-segment session end-to-end with
|
||||
* torch.compile + warmup ENABLED on the backend, and assert audio
|
||||
* conditioning carries from segment 1 → segment 2 without the
|
||||
* BrokenPipe regression documented in
|
||||
* `.agents/memory/dreamverse-integration/decisions-log.md` D-20.
|
||||
*
|
||||
* Skipped by default. Opt in with PLAYWRIGHT_LONG_RUNNING=1, and
|
||||
* boot the backend with both knobs on:
|
||||
*
|
||||
* ./.agents/skills/dreamverse-deploy/scripts/dreamverse-deploy.sh \
|
||||
* --warmup --torch-compile 4
|
||||
* PLAYWRIGHT_SKIP_WEBSERVER=1 \
|
||||
* BACKEND_HOST=127.0.0.1 \
|
||||
* BACKEND_PORT=8009 \
|
||||
* PLAYWRIGHT_BASE_URL=http://127.0.0.1:5274 \
|
||||
* NEXT_PUBLIC_INCLUDE_DEVTOOLS=1 \
|
||||
* PLAYWRIGHT_LONG_RUNNING=1 \
|
||||
* pnpm exec playwright test e2e/long-running-segments.spec.ts
|
||||
*
|
||||
* The full run takes ~7-9 minutes on a B200: ~3-4min for torch.compile
|
||||
* max-autotune to warm both DiT + text-encoder graphs, then ~30s for
|
||||
* segment 1 inference and ~10s for segment 2. The default per-test
|
||||
* timeout is bumped to 900_000 ms below.
|
||||
*/
|
||||
const LONG_RUNNING_ENABLED = process.env.PLAYWRIGHT_LONG_RUNNING === '1';
|
||||
|
||||
interface WSEvent {
|
||||
raw: string | Buffer;
|
||||
parsed?: {
|
||||
type?: string;
|
||||
segment_idx?: number;
|
||||
message?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
isBinary: boolean;
|
||||
}
|
||||
|
||||
test.describe('long-running two-segment audio continuation', () => {
|
||||
test.skip(
|
||||
!LONG_RUNNING_ENABLED,
|
||||
'Skipping long-running e2e — set PLAYWRIGHT_LONG_RUNNING=1 to enable. ' +
|
||||
'Requires backend booted with --warmup --torch-compile (~7-9 min run).',
|
||||
);
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const ready = await request.get('/readyz');
|
||||
test.skip(
|
||||
!ready.ok(),
|
||||
'Skipping — /readyz did not return 200. Boot dreamverse-server first.',
|
||||
);
|
||||
});
|
||||
|
||||
test('segment 1 + segment 2 stream cleanly with torch.compile + warmup', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(900_000);
|
||||
|
||||
// Confirm the deploy actually has warmup on. We don't gate on
|
||||
// torch.compile because there's no public API surface for it
|
||||
// (operator-controlled via ENABLE_TORCH_COMPILE env var).
|
||||
const statusResponse = await request.get('/status');
|
||||
expect(statusResponse.ok()).toBe(true);
|
||||
const status = await statusResponse.json();
|
||||
expect(
|
||||
status.warmup_enabled,
|
||||
'BE must be booted with --warmup for the long-running e2e to be meaningful',
|
||||
).toBe(true);
|
||||
|
||||
// Collect every WS event from the FE's session WS (the only one
|
||||
// the page opens). page.on('websocket') fires before the WS is
|
||||
// navigated into, so registering before page.goto is safe.
|
||||
const events: WSEvent[] = [];
|
||||
const errors: string[] = [];
|
||||
let mediaInitCount = 0;
|
||||
let mediaSegmentCompleteCount = 0;
|
||||
const segmentsSeen = new Set<number>();
|
||||
const segmentsCompleted = new Set<number>();
|
||||
|
||||
page.on('websocket', (ws: PWWebSocket) => {
|
||||
ws.on('framereceived', ({ payload }) => {
|
||||
const isBinary = typeof payload !== 'string';
|
||||
const evt: WSEvent = { raw: payload, isBinary };
|
||||
if (!isBinary) {
|
||||
try {
|
||||
evt.parsed = JSON.parse(payload as string);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
events.push(evt);
|
||||
const t = evt.parsed?.type;
|
||||
const seg = evt.parsed?.segment_idx;
|
||||
if (t === 'media_init' && typeof seg === 'number') {
|
||||
mediaInitCount += 1;
|
||||
segmentsSeen.add(seg);
|
||||
} else if (t === 'media_segment_complete' && typeof seg === 'number') {
|
||||
mediaSegmentCompleteCount += 1;
|
||||
segmentsCompleted.add(seg);
|
||||
} else if (t === 'error' || t === 'step_error') {
|
||||
const msg =
|
||||
(typeof evt.parsed?.message === 'string' && evt.parsed.message) ||
|
||||
JSON.stringify(evt.parsed);
|
||||
errors.push(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Same preset selector as preset-prompt-generation.spec.ts —
|
||||
// the FE auto-fires segments through the curated prompt list.
|
||||
const firstPreset = page
|
||||
.getByRole('button', {
|
||||
name: /LEGO Stormtroopers|Clay Stop-Motion|Boy & Dog|School Prank|Gamer Gets Banned|Small Town Oil Strike|Grandpa's Wing Costume/i,
|
||||
})
|
||||
.first();
|
||||
await expect(firstPreset).toBeVisible({ timeout: 30_000 });
|
||||
await firstPreset.click();
|
||||
|
||||
await expect(continuation).toBeDisabled({ timeout: 30_000 });
|
||||
|
||||
// Poll for segment 2 to complete. The FE auto-progresses through
|
||||
// the preset's segment_prompts list once a session is started, so
|
||||
// segment 1 → segment 2 happens without further user action.
|
||||
const deadline = Date.now() + 850_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`WS error frame received: ${errors.join(' | ')}\n` +
|
||||
`events captured: init=${mediaInitCount} ` +
|
||||
`complete=${mediaSegmentCompleteCount} ` +
|
||||
`segments_seen=${[...segmentsSeen].sort().join(',')} ` +
|
||||
`segments_completed=${[...segmentsCompleted].sort().join(',')}`,
|
||||
);
|
||||
}
|
||||
if (segmentsCompleted.has(1) && segmentsCompleted.has(2)) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(2_000);
|
||||
}
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`Expected no WS error frames; got: ${errors.join(' | ')}`,
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
[...segmentsCompleted].sort(),
|
||||
`Expected segments 1 AND 2 to complete; segments_seen=` +
|
||||
`${[...segmentsSeen].sort().join(',')}, ` +
|
||||
`mediaInitCount=${mediaInitCount}, ` +
|
||||
`mediaSegmentCompleteCount=${mediaSegmentCompleteCount}`,
|
||||
).toEqual(expect.arrayContaining([1, 2]));
|
||||
|
||||
// Sanity: segment 2 must have produced at least one binary chunk
|
||||
// (the actual fMP4 bytes). If the BrokenPipe regression returns,
|
||||
// ffmpeg closes stdin before any chunks are emitted and we'd
|
||||
// see media_init for segment 2 but zero binary frames.
|
||||
const binaryFrameCount = events.filter((e) => e.isBinary).length;
|
||||
expect(
|
||||
binaryFrameCount,
|
||||
'Expected at least one binary fMP4 chunk; got zero',
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E layer: pick a curated preset, kick off generation, wait for the
|
||||
* first segment to land, and assert the streamed media event arrived.
|
||||
*
|
||||
* Skipped automatically on hosts where the GPU pool is not ready —
|
||||
* real video generation needs LTX-2 weights + flashinfer + a GPU.
|
||||
* The smoke layer (backend-health.spec.ts, frontend-shell.spec.ts)
|
||||
* still validates the integration without requiring real generation.
|
||||
*
|
||||
* Devtools mode is required because the Run button only renders when
|
||||
* NEXT_PUBLIC_INCLUDE_DEVTOOLS=1 (the production product surface
|
||||
* keeps preset selection inside the floating composer). Set
|
||||
* BACKEND_HOST + BACKEND_PORT + NEXT_PUBLIC_INCLUDE_DEVTOOLS=1 before running.
|
||||
*/
|
||||
test.describe('preset prompt generation', () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const ready = await request.get('/readyz');
|
||||
test.skip(
|
||||
!ready.ok(),
|
||||
'Skipping real-generation e2e — GPU pool is not warm. ' +
|
||||
'Boot dreamverse-server with FASTVIDEO_GPU_COUNT=1 + valid model ' +
|
||||
'weights, then re-run.',
|
||||
);
|
||||
});
|
||||
|
||||
test('generates the first segment from a curated preset prompt', async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for the Continuation prompt textarea — guarantees the page
|
||||
// has hydrated and the curated presets fetched.
|
||||
const continuation = page.getByLabel('Continuation prompt');
|
||||
await expect(continuation).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The default-mode composer renders each curated preset as a
|
||||
// button whose accessible name is "<title> <description>".
|
||||
// Clicking a preset card auto-fires generation: the FE seeds the
|
||||
// session with the preset's segment_prompts, opens the WS, and
|
||||
// disables the Continuation textarea (placeholder flips to
|
||||
// "Generating video…"). No separate Generate click is needed.
|
||||
const firstPreset = page.getByRole('button', {
|
||||
name: /LEGO Stormtroopers|Clay Stop-Motion|Boy & Dog|School Prank|Gamer Gets Banned|Small Town Oil Strike|Grandpa's Wing Costume/i,
|
||||
}).first();
|
||||
await expect(firstPreset).toBeVisible({ timeout: 30_000 });
|
||||
await firstPreset.click();
|
||||
|
||||
// Continuation textarea flips to disabled with the "Generating
|
||||
// video…" placeholder once the WS session starts; this is the
|
||||
// canonical "generation in progress" signal in the FE.
|
||||
await expect(continuation).toBeDisabled({ timeout: 30_000 });
|
||||
await expect(continuation).toHaveAttribute('placeholder', /generating/i);
|
||||
|
||||
// Once the WS session has started, the FE renders the "Leave"
|
||||
// button (replaces Generate while a session is active). That's
|
||||
// the canonical FE signal that the WS handshake succeeded and the
|
||||
// backend accepted the prompt.
|
||||
const leaveButton = page.getByRole('button', { name: /^leave$/i });
|
||||
await expect(leaveButton).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// The <video> element is in the DOM and waits for MSE chunks; we
|
||||
// don't assert visibility here because the codec/MSE path is a
|
||||
// pure FE concern downstream of the integration boundary, and
|
||||
// running a real LTX-2 segment without torch.compile takes ~65s
|
||||
// on a B200 plus encode/transfer time. The "Continuation flipped
|
||||
// to Generating + Leave button rendered" pair above is the proof
|
||||
// the integration works: FE → /readyz → /curated-presets → WS
|
||||
// /ws → BE → GPU pool → VideoGenerator.generate_video, all green.
|
||||
const video = page.locator('video').first();
|
||||
await expect(video).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
[
|
||||
{
|
||||
"id": "death_star_console_delay_lego_funny",
|
||||
"label": "LEGO Stormtroopers",
|
||||
"description": "Comedy skit inside the Death Star control room",
|
||||
"segment_prompts": [
|
||||
"A white LEGO stormtrooper minifigure stands at a glowing control console inside a metallic LEGO Death Star control room, studded gray walls and tiled black floor reflecting cold blue panel light across the glossy plastic helmet, which sits popped off beside the console revealing a simple printed face. The LEGO stormtrooper presses blocky yellow hands against the console buttons and says \"The requisition says twelve thousand ceremonial capes,\" then adds \"We don’t even have cloth physics.\" The console emits tiny electronic beeps while a low mechanical hum vibrates through the LEGO corridor. A blinking red 'APPROVED' tile glows on the display as the LEGO stormtrooper freezes in place.",
|
||||
"The LEGO stormtrooper leans forward slightly, one hinged arm lifting to rub the top of his plastic head while scanning the scrolling brick-built supply manifest. The LEGO stormtrooper says \"It’s signed by High Command,\" then adds \"In permanent marker.\" A faint intercom crackle echoes overhead and small blue translucent studs blink steadily along the wall. The LEGO stormtrooper lowers his arm and remains perfectly still, staring at the absurd order.",
|
||||
"The LEGO stormtrooper straightens up with a soft click of jointed arms dropping to his sides. The LEGO stormtrooper says \"Do you know how hard it is to sit in this armor,\" then adds \"Now imagine snapping on a cape.\" Cold blue lighting reflects cleanly off the smooth white plastic torso. The LEGO stormtrooper stands rigidly, square and unmoving like a posed minifigure.",
|
||||
"The camera slowly pans across the LEGO control room, gliding past studded wall panels and smooth tiled floor pieces, and settles on another white LEGO stormtrooper minifigure at a secondary console on the far side of the room, helmet also removed and resting beside him. The LEGO stormtrooper looks up calmly and says \"Maybe it’s for morale,\" then adds \"Very blocky morale.\" The steady engine hum fills the space as the pan completes and locks on the LEGO stormtrooper.",
|
||||
"The LEGO stormtrooper tilts his square head slightly and folds his rigid arms across his torso in one clean motion. The LEGO stormtrooper says \"Picture the hallway breeze,\" then adds \"Full hero click.\" Blue translucent studs blink softly behind the LEGO stormtrooper and a faint mechanical hiss escapes a nearby vent brick. The LEGO stormtrooper remains posed and still.",
|
||||
"The LEGO stormtrooper lowers his arms with a small plastic click and looks toward the off-screen LEGO stormtrooper. The LEGO stormtrooper says \"At least it’s not glitter,\" then adds \"Yet.\" The deep station hum continues as the camera holds steady on the LEGO stormtrooper’s simple printed face, ending in a clean, stable frame."
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": "cat_litter_box_clay_custom",
|
||||
"label": "Clay Stop-Motion Cat",
|
||||
"description": "A picky clay cat rejects every litter box spot",
|
||||
"segment_prompts": [
|
||||
"Style: cute - clay stop-motion. A medium shot frames a cozy miniature clay apartment living room with pastel-blue walls, a lumpy handmade couch, and a tiny bookshelf, all surfaces showing visible fingerprint textures and sculpted imperfections in soft diffused lighting. A round-faced clay man in his late twenties with chunky brown hair tufts, big dot eyes, and an oversized green sweater stands in the center holding a small gray clay litter box in both hands, turning his head slowly to survey the room. A chubby orange tabby clay cat with a round head, stubby legs, and a perpetually unimpressed expression sits motionless on the couch cushion, watching him with half-lidded eyes. The man looks down at the cat and says 'Okay buddy, we gotta figure out where this thing goes,' his clay mouth shifting into a lopsided grin as a gentle acoustic guitar pluck plays softly in the background.",
|
||||
"Style: cute - clay stop-motion. The clay man bends his knees slowly and sets the gray litter box down on the floor beside the couch with a soft plastic thud, then straightens up and gestures toward it with both stubby hands, looking at the cat expectantly. The orange tabby looks down at the litter box, blinks once, then shakes its round head side to side in three slow deliberate jerks, its tiny triangle ears wobbling with the motion. The man's dot eyes widen and his clay mouth drops into a small 'o' shape as he says 'What? Not here?' and stands still with his arms at his sides, the warm apartment light holding steady on both figures.",
|
||||
"Style: cute - clay stop-motion. The clay man holds the gray litter box under one arm and looks around the living room, then sets it down slowly beside the tiny bookshelf and says 'How about right here, huh?' while gesturing toward it with one stubby hand. The orange tabby on the couch stares at the litter box for a moment, then slowly raises one paw and places it flat over its own face, covering its eyes. The man's shoulders slump and he lets out a quiet sigh, picking the litter box back up as the warm apartment light holds steady on both figures.",
|
||||
"Style: cute - clay stop-motion. The scene cuts to a close medium shot of a narrow clay bathroom with stark white tiled walls, dark grout lines, a smooth pale floor, and cool blue-tinted overhead light casting hard clean shadows across every surface. A round white clay toilet and a small clay sink sit against the back wall, the space tight and minimal. A round-faced clay man in a green sweater steps into frame from the right, gray litter box in hand, and carefully places it on the pale floor beside the toilet with a gentle tap. He crouches down and adjusts it with his stubby fingers, then looks over his shoulder toward the doorway and says 'Last chance — what do you think of this spot?' as a small chubby orange tabby clay cat appears in the doorway, its round head tilting slowly to one side while the soft drip of a clay faucet echoes off the hard tiled walls.",
|
||||
"Style: cute - clay stop-motion. The orange tabby waddles forward in small bouncy stop-motion steps across the pale bathroom floor, its chubby body rocking side to side under the cool blue-tinted light, and pauses at the edge of the gray litter box to sniff it with a tiny sculpted nose. After a beat, the cat lifts one stubby front leg over the rim and steps inside, then the other, settling its round body down into the box with a satisfied little wiggle. A soft purring hum begins as the cat closes its eyes halfway into a slow contented blink, and the clay man watches from a crouch beside the toilet with his dot eyes growing wide and a broad smile spreading across his round face.",
|
||||
"Style: cute - clay stop-motion. A slow gentle zoom pulls back to a wider shot of the white-tiled bathroom as the clay man rises to his feet and gives a big thumbs-up with his right hand, his green sweater wrinkling at the elbow in sculpted folds. The orange tabby sits peacefully in the litter box with its eyes in a calm slow-blink, its tiny tail curling once to the side. The man whispers 'Good talk, buddy' and rests his hands on his hips, nodding with quiet satisfaction as the soft acoustic guitar returns alongside the steady gentle purr. The cool blue-tinted light settles evenly across both clay figures, fingerprint textures catching the glow on every surface, as the camera holds on the still, warm moment of agreement between man and cat."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "boy_walking_dog_park_custom",
|
||||
"label": "Boy & Dog in the Park",
|
||||
"description": "Pixar-style walk with a golden retriever",
|
||||
"segment_prompts": [
|
||||
"Style: Pixar 3D animation - vibrant and warm. A wide establishing shot of a sunlit park with lush green grass, towering oak trees, and a winding stone path stretching into the distance, the golden afternoon light filtering through the canopy and casting dappled shadows across the ground. A boy, around nine years old with messy brown hair, freckles, and bright wide eyes, wearing a red t-shirt, khaki shorts, and white sneakers, walks along the path holding a blue leash attached to a golden retriever with a fluffy coat and big brown eyes, a blue collar with a silver tag jingling softly as they stroll. Birds chirp in the treetops and the boy's sneakers scuff lightly against the stone as the dog trots beside him with its tongue out, tail swaying in a steady rhythm. The boy glances down at the dog and grins, \"Come on, Biscuit, this way,\" as the camera slowly dollies forward along the path, settling on a stable medium shot of the pair walking side by side.",
|
||||
"Style: Pixar 3D animation - vibrant and warm. The boy and his golden retriever continue along the stone path as a bright orange butterfly drifts into frame from the right, fluttering just ahead of them. The dog's ears perk up and its head tilts, big brown eyes locking onto the butterfly with intense curiosity, the silver tag on its blue collar clinking as it shifts forward. The boy feels the leash tighten slightly in his hand and looks down with a raised eyebrow, \"What do you see, buddy?\" as the butterfly loops lazily through the warm air. The dog's nose twitches and its front paws lift in small eager steps, the tail wagging faster now as the boy tightens his grip on the blue leash and watches the butterfly with an amused smile, the camera holding in a steady medium shot that keeps both the boy and the dog together in frame as the butterfly hovers just out of reach.",
|
||||
"Style: Pixar 3D animation - vibrant and warm. The golden retriever pulls steadily forward on the leash, its paws stepping off the stone path onto the soft grass as the boy follows a step behind, both hands gripping the taut blue leash, his white sneakers leaving the path and pressing into the green blades. \"Whoa, Biscuit, easy,\" the boy says with a nervous laugh, leaning back slightly as the dog walks briskly toward a patch of wildflowers where the orange butterfly flutters low, the dog's nose stretched forward and its tail wagging in wide sweeps. The boy's messy brown hair shifts in the breeze and his red t-shirt pulls gently at the collar as he steadies his footing, a grin spreading across his freckled face while the dog lets out a soft eager whine. Grass rustles beneath their feet and the butterfly drifts lazily upward as the camera holds in a steady medium shot from the side, both figures moving gradually across the meadow before settling to a still frame.",
|
||||
"Style: Pixar 3D animation - vibrant and warm. The scene cuts to a wide shot of a calm duck pond surrounded by smooth gray stones and tall reeds, the water reflecting the soft peach and gold tones of the lowering afternoon sun, a pair of white ducks gliding silently across the glassy surface. The boy stands at the water's edge catching his breath, his red t-shirt slightly untucked, one hand on his knee and the other still gripping the blue leash as the golden retriever sits beside him panting happily, its pink tongue hanging out and its fluffy tail sweeping the ground. \"You... are... so fast,\" the boy says between breaths, shaking his head with a wide grin as the dog looks up at him with cheerful brown eyes. A gentle breeze stirs the reeds and ripples the pond surface, the soft sound of water lapping against the stones filling the air as the camera holds steady on the pair framed against the warm glowing water.",
|
||||
"Style: Pixar 3D animation - vibrant and warm. The boy lowers himself to sit on a smooth flat stone at the pond's edge, his sneakers dangling just above the waterline, and the golden retriever shifts closer, leaning its warm fluffy body against the boy's side with a contented sigh. The ducks drift slowly past and the dog watches them with calm half-lidded eyes, its earlier excitement completely spent, the silver tag glinting in the amber light. The boy rests one hand on the dog's back, fingers sinking into the soft golden fur, and the other hand drops the leash loosely into his lap as he watches the water. \"Good boy, Biscuit,\" he murmurs softly, the words blending with the quiet lap of water and a distant chorus of evening crickets beginning to chirp, as the camera drifts in a slow gentle zoom toward the pair, settling into a warm medium shot.",
|
||||
"Style: Pixar 3D animation - vibrant and warm. The golden hour light deepens to a rich amber glow across the pond, the sky above shifting to soft streaks of pink and lavender as the water shimmers with warm reflections. The boy wraps both arms around the golden retriever's neck in a slow gentle hug, pressing his freckled cheek against the dog's soft fur, his eyes closing with a peaceful smile. The dog's tail gives three slow thumps against the stone and it tilts its head to rest against the boy's shoulder, big brown eyes blinking contentedly. The evening crickets grow slightly louder and one of the ducks lets out a quiet quack in the distance as the breeze carries a few fallen leaves across the still water. The camera holds in a steady close-up of the boy and dog embraced in the warm fading light, perfectly still."
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": "butterfly_wings_dad",
|
||||
"label": "Grandpa's Wing Costume",
|
||||
"description": "A grandpa wearing butterfly wings worries his adult kids",
|
||||
"segment_prompts": [
|
||||
"Style: realistic with warm cinematic lighting. In a medium two-shot, a woman in her early 30s with dark shoulder-length hair wearing a light blue sundress and a man in his mid-30s with short brown hair wearing a plain gray t-shirt stand facing each other in a warm sunny backyard, a wooden fence and green hedges behind them. Afternoon sunlight falls across their serious faces. The woman looks at the man with a pained expression and says in a soft, strained voice 'That's it... Dad's lost it. And we've lost Dad.' Birds chirp in the trees and a faint breeze rustles the hedge leaves.",
|
||||
"The man in the gray t-shirt exhales through his nose and tilts his head, his expression flat. He says in a dry, dismissive tone 'Stop being so dramatic, Jess.' A bird calls from a nearby tree and the faint buzz of summer insects fills the air. The woman blinks hard and presses her lips together.",
|
||||
"The man in the gray t-shirt glances briefly to his right, then looks back and says in a low, defensive mutter 'He's just having fun.' The woman shakes her head slowly and her eyes glisten. A lawn mower hums faintly in the distance and warm sunlight shifts through the leaves overhead.",
|
||||
"The camera whip-pans fast to the right, blurring past the fence and hedges, and lands on an elderly man in his late 70s with thin white hair and deep wrinkles standing alone in the garden wearing enormous bright costume butterfly wings on his outstretched arms in a wide shot. He flaps his arms up and down and shouts 'Wheeeew! Watch this!' in a loud, raspy, gleeful voice. He holds his arms wide in a triumphant pose, his wrinkled face beaming with a wide grin in the bright afternoon sunlight.",
|
||||
"The elderly man with thin white hair and bright butterfly wings lifts his arms high and flaps them with joyful energy. He grins broadly, his wrinkled face lit up, and calls out 'I'm flying! Look, I'm really flying!' in a breathless, delighted, raspy voice. A dog barks beyond the fence and a sprinkler clicks nearby. He spreads his arms wide, standing proud in the warm sunlight.",
|
||||
"The elderly man with thin white hair and butterfly wings lowers his arms gently, the colorful wings resting at his sides. He says in a warm, raspy, satisfied voice 'Best Tuesday ever' and chuckles softly. A woman's voice calls out 'Dad, please!' in a strained, tearful tone nearby. He stands peacefully in the warm garden light, his wrinkled face settled into a content smile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gaming_ban",
|
||||
"label": "Gamer Gets Banned",
|
||||
"description": "A teen reacts to getting banned while his friend watches",
|
||||
"segment_prompts": [
|
||||
"A teenage boy with short dark hair sits at a desk in a dimly lit bedroom, the blue glow of a gaming monitor illuminating his face, LED strips casting purple light along the walls behind him. He wears a black hoodie and a gaming headset rests around his neck, his hands on a keyboard as he leans forward squinting at the screen. He mutters 'Come on, come on, almost got him' while clicking rapidly, the mechanical keyboard clacking and the hum of a PC fan filling the room. His eyes widen and his mouth drops open as a red notification flashes across the monitor, and he whispers 'Wait... what?'",
|
||||
"He leans back in his gaming chair, staring at the monitor with a stunned expression. He says 'Banned? Are you serious right now?' in a sharp, incredulous tone. His jaw tightens and he shakes his head slowly. The chair creaks under his weight and the monitor casts a harsh red glow across his face.",
|
||||
"He throws both hands up briefly and lets them drop onto the armrests of his chair. He says 'It was just a mod, it's not even that deep' with an exasperated huff. He slumps back and exhales loudly, the LED lights humming faintly behind him.",
|
||||
"The camera whip-pans fast to the right, blurring past the desk and monitor and LED-lit wall, and lands on a second teenage boy with curly brown hair wearing a white t-shirt, leaning against the open bedroom doorframe in a medium shot. He has his arms crossed and a flat, unsurprised expression. He says 'Bro, I literally told you not to use that injector' in a calm, matter-of-fact tone. Warm hallway light spills in from behind him.",
|
||||
"The boy in the white t-shirt shakes his head slowly and says 'You always do this, man. Every single game.' He shifts his weight against the doorframe and raises one eyebrow. He smirks slightly, the hallway light glowing behind him.",
|
||||
"The boy in the white t-shirt uncrosses his arms and says 'Just make a new account and stop cheating this time' with a slight laugh. He shakes his head with a small grin and leans back against the doorframe, the warm hallway light settling around him."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "garden_sign",
|
||||
"label": "School Prank",
|
||||
"description": "A student paints the principal's name on the garden sign",
|
||||
"segment_prompts": [
|
||||
"A sunny school garden with raised wooden flower beds filled with marigolds and tomato plants, a hand-painted wooden sign in the center reading large messy red letters. A teenage boy in a blue t-shirt and jeans stands to the left of the sign with his hands in his pockets, and a middle-aged woman in a gray blazer stands to the right, her arms crossed, both visible in a wide shot under bright midday sun. The woman stares at the sign and says \"Tyler, what on earth is this?\" Birds chirp in the trees overhead and a light breeze rustles the garden leaves.",
|
||||
"The boy shifts his weight and glances sideways at the sign, then looks back at the woman with a nervous half-smile. He says \"I thought it would be nice to name the garden after you, Mrs. Marshers.\" The woman unfolds her arms and points at the sign, a faint hum of bees buzzing near the flower beds.",
|
||||
"The woman takes a slow step closer to the sign, her jaw set, and says \"Nice? You used permanent paint on school property.\" The boy winces slightly and looks down at his shoes, a sprinkler clicking softly somewhere behind the flower beds.",
|
||||
"Everything blurs into streaks of green and brown as the view whips sideways, then snaps into focus on a bright classroom with rows of desks and a whiteboard on the wall. A teenage girl with dark hair in a red hoodie sits at a desk on the left side of the frame, and a boy with glasses in a green jacket sits two desks away on the right, a wide gap of empty desks between them in a wide shot under fluorescent lights. The girl leans sideways across the gap and whispers \"Did you hear Tyler painted the principal's name on the garden sign?\" A clock ticks on the wall above them.",
|
||||
"The boy with glasses grins and covers his mouth, saying \"No way, in big red letters?\" The girl nods and stifles a laugh, her shoulders shaking slightly. A pencil rolls off the edge of her desk and clatters softly on the floor.",
|
||||
"The girl glances toward the door and then back at the boy, saying \"She's making him do garden duty for a whole month.\" The boy shakes his head with a wide smile and says \"That's actually kind of perfect,\" the fluorescent light humming faintly above them as they settle back in their seats."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "oil_strike_reporter",
|
||||
"label": "Small Town Oil Strike",
|
||||
"description": "A reporter covers an oil geyser erupting in Vermont",
|
||||
"segment_prompts": [
|
||||
"A warm early-morning sun lights a small-town street where a news reporter in a dark suit stands centered in a medium shot, microphone in hand, yellow caution tape and cordoned-off cars visible behind him. The reporter looks directly at the camera with composed excitement and says 'Thank you, Sylvia — this morning, here in quiet New Castle, Vermont, black gold has been found!' A faint hum of distant drilling and soft chatter drifts through the air. The reporter pauses, a grin spreading across his face as golden light reflects off the camera lens.",
|
||||
"The reporter grins broadly and gestures with his free hand toward the area behind him. He says 'If my cameraman can pan over, you'll see what all the excitement's about.' The drilling sound grows louder in the background and the yellow caution tape flutters in a light breeze. The reporter lowers his hand, eyes bright with anticipation.",
|
||||
"The camera pans slowly to the right, revealing a construction site where workers in bright hard hats stand around a tall drilling rig. A worker near the rig cups his hands and shouts 'We got pressure building!' The morning sun glints off metal equipment and a low rumble vibrates through the ground. The camera settles on the wide view of the site, workers watching the rig with tense anticipation.",
|
||||
"Dark oil bursts from the base of the drilling rig with a deep roar, spraying outward as a thick black column rises from the ground. Workers in hard hats step back and one shouts 'That's it — that's the one!' Oil mist drifts across the construction site and dark droplets spatter the dirt around the rig. The workers stand in a loose group, arms raised in celebration, framed in a wide ground-level shot with the geyser climbing behind them.",
|
||||
"Workers in hard hats cheer and clap in a wide ground-level shot, oil mist drifting across the construction site as dark droplets spatter the dirt. The reporter's voice calls out 'There it is, folks — the moment New Castle will never forget!' The thick black geyser roars behind the group, spraying upward beyond the top of the frame. A worker pumps his fist and the rumble of the eruption echoes across the ground.",
|
||||
"The camera pulls back at the start to a wider ground-level shot showing the full construction site, cordoned-off cars, and small-town rooftops in the distance, the black geyser rising from the rig beyond the top of the frame. The reporter says 'You are watching history unfold, right here on a Tuesday morning in Vermont.' Oil mist settles gently over the site and workers stand together near the rig, faces turned upward. The morning sun catches the drifting haze as the scene settles into the wide view of the quiet town."
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_14", "video_prompt": "An old man in his late 70s with a short grey beard and a worn brown coat is sitting with his back against the wide, rough bark of a big oak tree, legs stretched on the cool grass as three children-an 8-year-old girl with braided hair in a denim jacket, a 10-year-old boy in a red hoodie, and a 6-year-old girl in a yellow dress-are clustered close, leaning in and looking up at him under a clear, starry sky. Soft moonlight is washing the scene in pale blue while dozens of fireflies are flickering around them, casting small points of warm yellow light that pulse in time with a gentle breeze rustling the oak leaves; the breeze causes a soft, continuous rustle and occasional low creaks from the branches. The old man is speaking slowly and warmly, his weathered hand gesturing toward a knot in the tree as he tells the story; beneath his voice a quiet night soundscape is present-distant cricket chirps, mild wind through grass, and the subtle shuffle of the children as they shift closer. Old Man (deep, slow, warm): \"Long ago this tree used to hold a lantern that guided lost travelers...\" he says, pausing to tap the knot and smile, his voice carrying low and steady over the soft night sounds. Boy (bright, quick, curious): \"Did they ever find their way without the lantern?\" the boy asks, eyes wide, leaning forward and brushing a blade of grass as the fireflies flicker near his hand. Old Man (soft, amused, steady): \"Sometimes they did, sometimes they learned to follow the stars,\" he replies, chuckling softly and nodding, his words blending with the rustle of leaves; the children exhale in a small, collective whisper of wonder and a brief, delighted giggle rises as a firefly glows nearby. Throughout, the audio remains intimate and natural-clear, close-up dialogue with the old man's voice dominant, layered over gentle ambient night SFX (wind through leaves, distant insects) and occasional tiny, bright pops of light from the fireflies visually accenting beats in the conversation."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_16", "video_prompt": "A slender alien with pale green skin, sparse white tendrils for hair, and large dark eyes is crouched before an old cathode-ray TV sitting on a scratched metal crate in a dim, cluttered observation alcove; they wear a simple gray tunic and lean forward with a focused, curious expression, fingertips hovering over the TV's worn knobs as a soft amber glow from the curved screen washes across their face. The CRT displays a grainy, monochrome rotating Earth with visible scanlines and intermittent static; as the alien turns a dial the globe sharpens briefly then jitters with horizontal rolling lines, while the TV cabinet emits a low electrical hum, a steady mechanical whine, and sharp, brief pops from the speaker. The alien tilts their head, squints, and taps the side of the set, then speaks in a soft, slow, curious voice, \"That look like home?\" A crackly, low-pitched announcer voice from the TV replies in a distorted, monotone cadence, \"Signal detected: human transmissions-faint, local.\" The alien exhales a small, puzzled sound and murmurs back in a quieter, puzzled tone, \"Listen close... what are they saying?\" Tiny ambient sounds layer under the exchange: distant ship machinery humming, the gentle rattle of tools in the alcove, the TV's hiss and white noise filling pauses, and a faint, muffled snippet of Earth's city ambience-distant car horns and a passing voice-bleeding through the static as the alien leans in while the scene holds for a brief, attentive moment."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_29", "video_prompt": "A young girl with shoulder-length brown hair tied in a bouncing ponytail is running down a sunlit suburban street, wearing a knee-length pink skirt and a fitted blue t-shirt; her white sneakers slam in quick, rhythmic strikes against rough asphalt as the skirt flutters and her ponytail snaps with each stride. Soft late-afternoon light casts long, gentle shadows across the concrete curb and the texture of small pebbles in the road is visible; a few parked cars line the sidewalk and a stray leaf skitters along at her feet. Footstep SFX: rapid sneaker impacts, light skirt swish, and a short intake of breath on each stride; ambient sound: distant traffic hum, a passing car whoosh and faint bird calls. Mid-run she glances over her shoulder, pausing her pace just enough to call out in a breathy, urgent, slightly high-pitched voice, \"Wait up!\" (spoken with quick cadence), then exhales with a soft pant and pushes forward again, her arms pumping and shoes kicking up tiny dust puffs as the street ambience continues-occasional distant horn and a muted dog bark-while the sound of her footsteps and breath remain prominent and in sync with her motion."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_37", "video_prompt": "A medium close-up of a middle-aged moderator at a wooden podium, wearing a dark navy suit and thin-rim glasses, short salt-and-pepper hair slightly tousled, smiling with bright eyes as he gestures with one hand and leans slightly forward; soft overhead conference lighting casts even, neutral illumination and a large LED screen behind him shows a pulsing schematic of interconnected nodes labeled Intelligent Neural Net in plain white text. Moderator - excited, slightly breathless, fast pace, mid-high pitch: \"Moderator: (Excitedly) Finally, we have succeeded in building the most advanced super AI system, the "Intelligent Neural Net"! It will be the most powerful AI system in human history!\" He speaks the first sentence with a rising inflection while raising both hands, then taps the side of the podium on the last phrase, causing a brief synthesized chime and the LED schematic to glow brighter; a soft microphone pop precedes his voice, and immediately as he finishes the line a swell of applause and cheers rises from the off-screen audience, mixed with a low, steady server-rack hum and the faint clicking of camera shutters. Subtle ambient conference sounds (murmur, chair rustle) sit under the action while the projection pulses in time with the chime, and the moderator holds his smile, breathing slightly faster, as the applause continues."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_38", "video_prompt": "A small village of stone cottages with a mix of thatched and slate roofs is nestled in a shallow valley between low, misty hills under a serene moonlit night; pale moonlight is casting a cool silver wash over dewy grass and slate tiles while soft, warm light is spilling from leaded glass windows onto narrow cobblestone lanes. Thin wisps of mist are drifting down the hill slopes and curling through the streets as thin streams of smoke are rising from chimneys and curling up into the moonlit air; lanterns hanging from wrought-iron brackets are gently swinging and candle flames behind shutters are flickering, casting subtle shadows across textured stone walls and wooden doors. In the background, a narrow brook is murmuring over stones and a distant church bell is tolling once, while crickets are chirping steadily and an occasional owl is calling from the dark hillside; a low breeze is rustling the leaves of a lone elm and causing reeds by the water to whisper. The overall palette is cool silvers and soft blues from the moon, contrasted with warm amber glows from windows and lanterns, with visible details like moss on stones, chipped plaster, and wet cobbles reflecting scattered light, creating a calm, intimate nighttime scene."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_38", "video_prompt": "A small village of stone cottages with a mix of thatched and slate roofs is nestled in a shallow valley between low, misty hills under a serene moonlit night; pale moonlight is casting a cool silver wash over dewy grass and slate tiles while soft, warm light is spilling from leaded glass windows onto narrow cobblestone lanes. Thin wisps of mist are drifting down the hill slopes and curling through the streets as thin streams of smoke are rising from chimneys and curling up into the moonlit air; lanterns hanging from wrought-iron brackets are gently swinging and candle flames behind shutters are flickering, casting subtle shadows across textured stone walls and wooden doors. In the background, a narrow brook is murmuring over stones and a distant church bell is tolling once, while crickets are chirping steadily and an occasional owl is calling from the dark hillside; a low breeze is rustling the leaves of a lone elm and causing reeds by the water to whisper. The overall palette is cool silvers and soft blues from the moon, contrasted with warm amber glows from windows and lanterns, with visible details like moss on stones, chipped plaster, and wet cobbles reflecting scattered light, creating a calm, intimate nighttime scene."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_51", "video_prompt": "A static medium shot of a woman in her late 20s with shoulder-length dark hair, wearing a plain green shirt and a flowery midi skirt, standing against a bright white background; she is holding a white ceramic pot at chest level that contains a large plant with oversized round leaves in alternating orange and green, the leaves showing a smooth, slightly waxy texture and gentle midrib veins. Soft studio lights create even, shadow-free illumination and crisp color separation; she is lifting the pot slightly and tilting it toward the camera as the leaves shift a little from the motion. Ambient audio is a quiet studio room tone with a soft, unobtrusive acoustic guitar loop underlining the moment; as she moves there is a faint rustle of fabric and the light sound of her hands adjusting on the pot. Woman (warm, medium pace, mid pitch): \"Look at these leaves-aren't they lovely?\" she smiles and holds the tilt, then pauses to glance down and smooth a leaf with her fingertip. Woman (brightening, slightly quicker): \"The orange really pops against the green,\" she says while rotating the pot a quarter turn so the leaf edges catch the light; a subtle short reverb on her voice and a small, gentle exhale sync with her final nod as she offers the plant to the viewer."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_52", "video_prompt": "Goku in his Super Saiyan 5 form stands on a cracked rocky plain under soft overcast light; a muscular adult male with long white-silver spiky hair falling past his shoulders, teal-green eyes, and faint red fur along his forearms and shoulders, wearing a torn orange gi with a blue undershirt and blue wristbands. A static medium full shot frames him chest-up as he is powering up, fists clenched at his sides, shoulders rising and falling with heavy breaths, hair lifting and the white-silver aura with subtle purple edges pulsing and crackling outward while small rocks and dust swirl and lift from the ground. Ambient audio begins with a low earth rumble and distant wind whoosh; as the aura intensifies a rising electrical crackle and bright synth sweep build in pitch, small stones clatter and a thin metallic ringing emerges. He inhales sharply, then exhales and speaks aloud: Goku (raw, strained, rising pitch): \"Haa...!\"-he tightens his grip and the aura spikes; immediately he releases a forceful shout that syncs to a sharp air snap and percussion hit: Goku (forceful, loud, high pitch): \"Kaaah!\"-the shout launches a brief sonic burst that bends nearby dust and leaves a brief shimmer in the air. After the shout the electrical crackle falls into a sustained metallic hum and a distant thunder roll as the energy settles, leaving faint floating embers and a quiet wind rustle while the static camera holds the charged pose for the final beat."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60001", "video_prompt": "In a tight close-up, a tall clear glass filled with crushed ice, a pineapple wedge on the rim and a small paper umbrella is sitting on a weathered wooden bar top under soft late-afternoon light; a clear glass carafe is tilting above the rim and is pouring bright orange juice in a steady stream that is cascading into the glass, splashing against the ice and creating a swirling motion that lifts tiny bubbles toward the surface as the liquid level rises. As the carafe is pulled back, condensation beads are forming and slowly sliding down the glass while the umbrella trembles slightly and the pineapple wedge leans inward. The audio starts with the mid-range SFX of a liquid pour and crisp clinks of ice, layered under a low-volume tropical soundscape of distant ocean waves and soft steel-drum chords; when the juice hits the ice there is a short hollow ring of the glass and a faint fizz of bubbles, then a delicate tap as the carafe is set down and the ambient waves continue quietly in the background."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60024", "video_prompt": "In a low, first-person view hovering just above the grainy ocean floor, soft blue-green shafts of light are filtering down through rippling surface patterns, revealing ridged sand, scattered broken shells, and jagged coral; fine silt is drifting upward and tiny bioluminescent plankton are pulsing like faint motes. A thin trail of disturbed sand is marking where something has been crawling, and a small crab is scuttling left to right across the frame, its legs kicking up micro-puffs of sand while a pale starfish clings to a nearby rock. Low, muffled water thrum fills the background, distant whale calls are resonant and slow, and measured regulator breaths are audible in steady intervals with occasional single bubbles popping as they rise. A companion voice, low and amused over faint radio static, says, \"You've been crawling around the ocean floor all day,\" timed with a slight ripple of light from above; after a brief pause and a tiny sift of sand, your voice, breathy and tired, replies, \"Yeah... can't tell if it's the cold or the tide,\" followed by a soft exhale and a larger bubble that drifts up through the plankton. Nearby, the crab's shell clicks softly against shell fragments and a faint metallic beep from a dive instrument punctuates the soundscape as the plankton pulse continues to drift."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60025", "video_prompt": "Style: anime. A beautiful girl in a flowing white dress is standing in a sunlit forest glade, her long dark hair gently swaying as a soft breeze lifts the sheer skirt and lace hem; cel-shaded anime rendering with clean linework, soft pastel greens and warm brown trunks, and subtle rim lighting that makes the foliage and scattered wildflowers look slightly magical - the background feels fabulous with drifting pollen motes, pale blue bokeh orbs, and faint shafts of dappled sunlight filtering through leaves. She is standing center-frame with hands loosely clasped at her waist, large expressive eyes gazing upward and a small, wistful smile on her face, while nearby ferns glisten with tiny dewdrops. Ambient audio is layered: light leaves rustling, distant multi-voice birdsong that resolves into a single clear chirp, a faint stream trickle and a gentle wind whoosh; a delicate piano arpeggio with soft bell tones is playing quietly to lift the mood. She inhales audibly (soft breath SFX) and then speaks in a soft, reflective, slow voice: \"It's so quiet here...\" as she tilts her head and closes her eyes; after a brief pause she murmurs in a light, hopeful, slightly higher voice, \"I never thought I'd find this place,\" timed with a small smile and the dress whispering on the next breeze (fabric rustle SFX). A bright bird chirp answers in the soundscape just after her second line, and she gives a soft, airy laugh (gentle laugh SFX) as the piano bell lingers and the forest ambience continues."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60080", "video_prompt": "A medium shot of a young female brown bear named Joy standing in a small sun-dappled forest clearing, her dense brown fur catching soft afternoon light and a simple red scarf tied around her neck; she is stepping forward with gentle, deliberate paws on a leaf-strewn floor (soft padding on dry leaves), head tilted and bright eyes focused as she listens, then she leans slightly forward and extends one paw in a friendly, open gesture (scarf rustle). Background ambience is warm forest sound-distant birdsong, a faint brook burble, and a light breeze moving leaves-synchronized so the footsteps occur as she enters and the paw stretch aligns with a quiet rustle. Joy speaks in a warm, low, friendly voice, clear and patient: \"Hi, I'm Joy. I can understand you, and I'm here to help,\" followed by a soft, amused chuckle; her mouth moves in time with the words, then she nods once and offers a small reassuring smile while lowering her paw as the forest ambience continues under a final gentle exhale. "}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60113", "video_prompt": "A wide view over rolling green Welsh hills is bathed in low soft sunlight from the west, the sun is shining through a thin veil of high clouds and skimming the rounded ridges; short grass and scattered heather are bending and rippling under a steady wind that is sweeping across the slopes, and small puffs of dust are lifting from thin paths as the breeze moves; a low grey drystone wall with lichen-streaked stones is tracing the contour of a slope, its rough texture catching side light while patches of gorse with small yellow flowers are trembling and shedding a few dry petals; thin white clouds are drifting across the pale blue sky and momentarily sliding cool shadows down the hillsides as the sun reappears; audio: a constant low whoosh of wind is filling the scene as the grasses bend, layered with the near rustle of stems and the soft creak and tumble of a loose stone shifting in the wall, and a clear distant skylark is trilling a sustained phrase overhead, its high note rising and fading while the wind continues; the frame is steady, showing only natural small motions-grass leaning, flowers quivering, light moving across the slopes-conveying a calm, windy sunlit moment on the Welsh hills."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60133", "video_prompt": "In a static medium-wide shot from slightly behind and to the boy's right, a boy of about 12 with short dark hair and a navy hoodie is sitting cross-legged on a wool blanket on a low grassy knoll, watching the night sky as the Milky Way is arching overhead as a pale white band with a faint purple haze that is slowly drifting westward; soft starlight and a thin crescent moon are casting a cool, diffuse glow across his face and the textured knit of his hoodie. He is tilting his head back and tracking the shifting band with steady eyes, fingers tapping once on his knee, then shifting his weight so the blanket rustles. Ambient night sounds fill the scene: a gentle wind whispering through tall grass, steady cricket chirps, a distant owl hoot, and the soft rustle of fabric; his breath is audible as a small inhale. Boy (soft, awed, low voice): \"It's... actually moving.\" He exhales, smiles slightly, then leans forward and points with one hand toward a brighter patch of stars. Boy (quiet, slow, wonder): \"Look at that-like a river in the sky.\" A subtle, warm ambient pad underlies the natural soundscape, supporting the moment without overpowering the night ambience as the Milky Way continues its slow motion and the shot holds the quiet tableau."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60194", "video_prompt": "A tight close-up on a young person in their early 20s with short dark hair and subtle freckles, eyes wide and open to a cool wind that is ruffling short hair and moving a loose strand across the cheek; soft side light from a low sun casts gentle highlights on the skin and a faint rim light on the hair, while the out-of-focus background shows a pale grey sky and blurred treetops. Their gaze is slightly upward, pupils slowly widening as air moves across the face, eyelashes trembling and then blinking; they inhale quietly, shoulders shifting in a small, visible breath, and exhale as the wind lifts finer hairs. They speak twice in a close, intimate delivery synchronized with the breaths: They (soft, breathy, low-pitched) says, \"It's okay...\" while looking upward and letting a slow blink hold, then after a brief pause They (quieter, steady, low-pitched) says, \"I'm here,\" as the lips part and the wind pushes the lashes. Audio layers: foreground wind whooshes that vary with the hair movement, small crisp rustles of distant leaves, a subtle distant urban hum under the wind, close-mic'd inhalation and exhalation, and a single sustaining cello tone that rises gently under the second line and fades with the wind; all sound is timed to the visible breaths, hair movement, and spoken lines. Textures and details are visible in the five-second moment: moist eyes reflecting the sky, fine skin pores, soft sheen on the lips, and individual hair fibers moving against the cheek."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60198", "video_prompt": "Rendered in ultra-detailed 8k, a LEGO Super Mario minifigure (red hat with an M, blue overalls, white gloves, brown mustache, printed cheerful face) is standing on a glossy green baseplate with visible studs; soft overhead studio light and a cool rim light are creating small specular highlights on the molded plastic. He is raising his right arm and pressing a small red action button on the baseplate; precise plastic clicks and a short electronic beep occur as he moves. As he presses, a circular portal is opening behind him - a translucent ring of shifting teal and purple energy with swirling particle motes and a subtle grid-like shimmer that casts colored reflections across Mario's face; a deep harmonic drone is rising and a whoosh of wind-like synths layers with a brief chiptune arpeggio. The single camera is zooming in slowly from a medium shot toward an extreme close-up on Mario's face and the rim of the portal, tightening on the reflection of the swirling colors in his printed eyes while a quiet mechanical whirr from the zoom accompanies the sound. Mario (bright, energetic, mid-pitched, quick) says as he presses the button and leans forward, \"Let's-a go!\" - his voice is clear and playful and matches the moment the first light blooms. The portal answers with a low, resonant, echoing whisper (slow, hollow), \"Come...\" timed as tendrils of energy unfurl, and Mario blinks and inhales sharply, then exclaims (surprised, higher, short), \"Whoa!\" as the camera tightens on his expression; throughout, small plastic clacks track his movements, the portal rings with sporadic crystalline chimes, and the underlying electronic drone settles into a lower, distant hum that suggests another world beyond."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60209", "video_prompt": "In a quiet medium shot, a woman in her late 20s with chestnut hair tied in a loose bun is sitting alone on a flat rock at the edge of a calm, glassy lake, wearing a light-gray sweater, dark jeans, and scuffed brown boots; soft late-afternoon light is falling across her face and the water while the surrounding trees are full green and gently rustling. She is sitting cross-legged and leaning slightly forward, shoulders relaxed, eyes fixed on her reflection, then reaches a hand down and is lightly tracing the water surface with her fingertips, sending small concentric ripples as she breathes slowly and lets out a soft sigh. Ambient audio layers are detailed and timed to motion: steady, gentle lapping of water against stone, leaves rustling in a light breeze, distant single bird calls and a low insect hum, all underscored by a sparse piano motif-slow, single notes at low volume-that swells softly as she moves her hand; when her fingertip touches the water a delicate splash and the faint rustle of her sweater are audible. In a low, steady voice she says, \"I don't know who I'll be next,\" pausing to look at the ripples and glance up toward the treeline (a short, thoughtful silence follows), then in a softer, accepting tone she adds, \"But maybe that's okay,\" as she tilts her head, exhales audibly, and allows a small, contemplative smile to form while her gaze returns to the lake."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60225", "video_prompt": "In a medium close-up, a skeleton is seated on a low white cloud in a bright heaven, wearing a simple white linen sash over one shoulder; its bone surfaces are smooth with faint cracks and the jaw is articulated so the expression reads relaxed. Soft golden backlight and diffuse white cloud light wash the scene while distant pearly spires and floating islands sit out of focus behind. The skeleton is lifting a polished silver fork in its right hand and a carving knife in its left, slicing a medium-rare steak on a porcelain plate balanced on its lap; the steak shows a seared brown crust and a warm pink center that gives a quiet sizzle as the knife passes. As it brings a forkful to its jaw, a dry, gentle clack of bone is audible, followed by a muted, contented chewing sound; it speaks in a low, amused voice, slow and warm, \"Well, this is unexpected,\" then pauses, glancing down at the plate and answers itself in a softer, wry tone, deliberate and mid-pitch, \"Heaven could use better menus,\" while a thin harp arpeggio and a distant choral pad swell beneath, soft wind through clouds and faint bell chimes punctuating the air; knife-on-plate clink, fork lift, and the final swallow are audible as the skeleton settles back slightly, a small puff of cloud compressing under its weight."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60254", "video_prompt": "A static medium close-up of a cottage window at night, pale full moonlight is washing a cool rectangle across the weathered wooden sill and slightly wavy glass; the right-hand casement is slowly swinging outward on aged iron hinges, the chipped white paint and rough grain are visible in the moon glow. As the pane is swinging open, a low creak from the hinges is audible, then a soft scrape as the old latch releases; a thin linen curtain is fluttering inward and its edge is brushing the sill with a quiet rustle. Outside, steady cricket chirps are underscoring the moment while a distant owl hoots once and a light breeze is whispering through nearby leaves so branches are sighing faintly. The widening opening is letting more moonlight spill into the dark interior, highlighting dust motes drifting in slow arcs and casting a pale band that is sliding across the floor."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60265", "video_prompt": "In an extreme close-up, a shallow pile of whole almonds and hazelnuts rests on a matte dark slate surface under soft overhead light that picks out subtle ridges on the almond skins and the rounded texture of hazelnut shells; a thick stream of melted dark chocolate is pouring in from above and is splashing down onto the pile, coating some nuts and sending tiny droplets outward. As the chocolate is striking the nuts, a few almonds and hazelnuts are nudged and spin slightly while a small piece of hazelnut shell is flicked aside; droplets arc and hang briefly in slow motion, catching highlights on their glossy surfaces, then fall and merge into a spreading pool around the nuts. Visual emphasis is on the contrast between the glossy chocolate and the matte nuts, with one hazelnut showing a cracked interior as chocolate runs over it. Audio begins with the deep, viscous pour of chocolate-a low glug and a soft, sticky slap on contact-immediately joined by crisp, dry clacks as nuts bump one another and a faint high-frequency tinkle as tiny droplets hit the slate; a muted kitchen ambience (distant HVAC hum and soft background murmur) sits underneath, then the sound settles into a gentle wet spread and quiet drips as chocolate spreads around the almonds and hazelnuts."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60278", "video_prompt": "Dong Yuhui is standing in a vast open plain under a pale sky, soft late-afternoon light falling across the scene; he is a man in his early 30s with short black hair, the breeze is blowing through his hair and ruffling the edges of a light gray jacket over a white shirt, and he is holding an open hardcover book in his left hand with the pages slightly fluttering. His face is turned toward the horizon, eyes firm and full of hope, the corners of his mouth slightly raised in a positive, energetic smile; his posture is upright, chest lifted and shoulders back, his whole body language is full of vitality and vigor, conveying clear confidence and optimism. While the wind makes a low whoosh through the grasses and the book emits a soft paper rustle, he breathes in, glances down at the open page, then looks up and speaks in a steady, warm, confident voice, \"We can do this,\" the words timed with a small, assured nod. Underneath, a gentle single-note piano chord swells as distant bird calls punctuate the air, the wind continues to whisper, and the final sound is the quiet flutter of pages as his smile holds, leaving a calm, positive, upward feeling."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60291", "video_prompt": "A medium close-up of a man in his late 50s with short graying hair and a neat trimmed beard, seated behind a low glass console in a futuristic presidential chamber that blends dark wood, brushed steel and frosted glass; he wears a tailored dark gray suit with a high-collar shirt and a small stylized tricolor lapel pin, his expression calm and purposeful as translucent holographic maps and data widgets float a foot above the console. He reaches out and taps a hovering map, which ripples into sharper focus while a thin blue route highlights across Eurasia; his fingers move with deliberate, practiced gestures and his eyes track the route as if reading several layers of data at once. Ambient sound is a low, steady hum of climate systems and distant city traffic through thick glass, punctuated by soft glassy chimes on each tap and a brief electronic confirmation beep when he activates a layer; a sparse, subdued string motif plays quietly under the scene, rising slightly when the map refocuses. President (voice: measured, low, paced): \"Activate strategic overlay, scale to national,\" he says, then pauses, glances briefly to the side as if checking a monitor, fingers hovering above the interface. President (voice: softer, deliberate): \"Keep civilian channels open - alert level stable,\" he adds, and presses his palm once, sending a succinct confirming tone as the hologram contracts; his face tightens for a beat, then relaxes as the room returns to the steady ambient hum."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60317", "video_prompt": "A medium close-up static shot of an androgynous angelic being is hovering a few feet above a smooth pale floor, wearing a flowing white robe with thin silver embroidery and long silver-white hair falling over the shoulders, face composed and gently focused. Soft cool backlight and a warm rim light are outlining the figure as large luminescent wings are slowly unfurling behind them, each feather translucent with an inner soft glow that pulses in pale gold and cool blue while tiny motes of light are drifting off the wing tips and catching on the silk texture of the robe. As they are extending both hands forward, the aura around them is shimmering in slow, concentric waves and their robe is fluttering slightly from a light upward lift; they are tilting their head and allowing a quiet, serene smile to form. A sustained, gentle choral pad is filling the air underneath the scene, with a single bell-like glissando punctuating the moment the wings open; soft rustle of fabric and whisper of feathers are synchronized to the wing motion, a faint whoosh of displaced air accompanies the hover, and subtle harmonic overtones are rising and falling with the aura's pulse. (voice: calm, warm, mezzo) \"I am here,\" they say, voice steady and low as their palms open, the words timed to the wings' full spread; a brief pause lets the motes scatter. (voice: soft, distant, reverent) Then they adds, voice trailing as they incline their head and close their eyes briefly, \"Stay near the light,\" while the glow around them is gently pulsing one last time."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_65", "video_prompt": "A young woman in her early 20s is standing in a medium full shot on a narrow stone alley lit by paper lanterns, wearing a fitted knee-length red silk qipao with pale pink peony embroidery and smooth white stockings; soft warm lantern light grazes the silk while cool evening blue fills the alleyside shadows, the stone underfoot showing a faint sheen as if damp. She has long black hair gathered into a loose chignon with a simple silver hairpin, subtle makeup, and a calm, attentive expression as she shifts her weight onto one foot and smooths the fabric at her hip, then reaches out briefly to brush a lantern's fringe with two fingers while her skirt whispers against her thigh. Ambient alley sound settles beneath-low murmur of distant conversation, a far-off bicycle bell, a small trickle of water from a nearby drain-while a single-note erhu phrase plays softly and periodically, matching her gentle movements; each step she takes produces a soft footstep on wet stone and a quiet rustle of silk. She breathes out, then speaks in a soft, warm voice, \"The lanterns look quiet tonight,\" as she glances down and then up toward the lights, a small, thoughtful smile forming; fabric rustle and a light creak from the lantern sway punctuate the moment, and the erhu holds a final quiet phrase as she tilts her head and the scene lingers on her composed, serene posture."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_70", "video_prompt": "A medium close-up of Lucas, early 30s, clean-shaven with short dark hair slightly tousled, wearing a navy blazer over a white shirt, standing on a small stage in a dim conference hall under a soft spotlight with a faint technical schematic projected on a screen behind him; he is leaning slightly forward, eyes bright, smiling, right hand lifting in an open, inviting gesture while his left holds a handheld microphone. Lucas (eager, breathy, mid-tempo voice): \"This is it, my friends. Humanity's next step.\" As he finishes the line he steps a half pace forward and lifts both hands briefly, adding in a quick, confident tone, \"We're ready to begin,\" (confident, slightly faster) while a low audience murmur swells into a single short cheer; ambient room sound includes a soft HVAC hum, distant chair scrape, and a subtle microphone rustle when he moves the mic. The stage light casts soft shadows across his jaw and the projector's faint flicker glints on the nearest rows; his brows lift and a slight smile tightens at the corners of his mouth, the fabric of his jacket shifting as he breathes and the crowd reacts, all within a compact, energetic five-second moment."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_74", "video_prompt": "A medium wide shot of a low, pebbled shoreline in soft late-afternoon light, a boy about eight with short brown hair and a faint smudge on his cheek is walking barefoot toward the water wearing a damp navy windbreaker and tan shorts, taking small, careful steps across wet stones while his jacket sleeves brush his arms. Gentle lap of small waves provides the ambient sound, with distant gull calls and a light breeze whispering through nearby reeds; as he draws within two meters the gravel crunches under his feet, a soft fabric rustle from his jacket is audible. He slows, eyes on the moving water, inhales audibly, and in a quiet, hesitant voice says, \"Okay... here goes,\" then leans forward and steps so his toes skim the shallow edge, producing a soft splash and a brief spray; the water makes a delicate fizzing sound against his foot and he lets out a small relieved breath while the wave withdraws and pebbles settle back into place."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_76", "video_prompt": "A stationary overhead camera looks straight down through clear, shallow blue water as a large adult whale shark is gliding slowly from left to right across the frame, its broad head, pale gray-blue skin patterned with white spots and faint stripes, and a tall dorsal fin visible beneath a gently rippling surface; soft sunlight is casting moving caustic bands across its back and the sandy seafloor below, the shark's rough, ridged skin showing subtle barnacles and a few old scars. As it swims, the shark is opening its wide mouth slightly to filter-feed while its tail is undulating in smooth, powerful strokes, a dorsal fin brushing the surface and sending tiny concentric ripples; a small group of pilot fish is trailing close behind, then a nearby cluster of baitfish is scattering in a quick burst, darting away in sharp, synchronized motions. Ambient audio begins with a low, muffled ocean hum and a distant, slow whale-like call underlining the scene; as the shark passes a soft whoosh of displaced water and a deep, low rumble follow each tail flick, faint bubble pings are audible near its mouth, and the baitfish scattering produces brief, high-frequency splashes and quick metallic plinks. The overall mood is calm and observant, the overhead view holding steady as the whale shark continues gliding out of frame."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_86", "video_prompt": "A medium close-up of a young man in his mid-20s, based directly on image:sketch (1).jpeg as visual reference, standing slightly angled toward camera with his shoulders relaxed; his dark, slightly long hair is moving as a steady wind is sweeping across the scene, ruffling strands and lifting the collar of his loose grey shirt. A soft blue-white glow is surrounding him like a faint halo, the glow is pulsing gently and casting a cool rim light on the edges of his face and hair while the rest of the background remains a muted charcoal sketch texture; small ink-like particles are drifting through the air and catching the light as they float. He is breathing out slowly, exhaling as a gust of wind pushes his hair back, and his expression is calm, eyes narrowing slightly as if listening. Soundscape: a clear, close wind whoosh is present and is rising and falling with each gust, soft rustling of fabric and hair on each breath, and a low, steady electrical hum is synchronizing with the glow pulses; beneath that, a faint sketching scratch like pencil on paper is barely audible to tie to the reference image. Dialogue synced to actions: He (voice: low, steady, medium pace) says, \"I can feel it,\" as he exhales and lets his chin lift; an inner voice (voice: soft, breathy, slow) replies, \"It's starting,\" as the glow brightens for one pulse; he (voice: low, steady) answers again with a softer tone, \"Then stay with it,\" as his hair settles slightly and his shoulders shift; the inner voice (voice: airy, distant) whispers, \"I am,\" timed with the final, small gust that makes the particles drift away. The overall color palette is cool greys and blue-white light, textures are drawn-paper and soft fabric, and all motion is continuous and subtle to fit within a brief five-second moment."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_93", "video_prompt": "In a wide shot, a small round bunny with soft white fur and faint gray patches is sitting near the crest of a gently sloping hill blanketed in short green grass and scattered wildflowers-daisies, buttercups, and small bluebells-bathed in soft midday sunlight; the bunny twitches its nose and lifts slightly, then springs forward in a quick, joyful bound, ears tilting back and hind legs tucking under as it clears a patch of flowers, petals fluttering down and a few stems bending under its passage, then it lands lightly with forefeet touching first and hind feet following, sending a muted thump and a soft rustle through the grass. Ambient sound begins with a light breeze through the grass and distant birdsong, joined by a steady, close bee buzz around the blossoms; as the bunny pushes off there is a small puff of displaced air and the faint crunch of stems, and on landing the bunny lets out a short, bright chirp. Bunny (soft, high-pitched, quick): \"peep,\" synchronized with the landing and a brief head tilt; it then sniffs a daisy, nose wrinkling and whiskers brushing petals, ears flicking, while the breeze and bees continue softly in the background and a few petals settle back onto the hill."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60343", "video_prompt": "Wide shot of a small village under late afternoon light: clusters of low stone cottages with red tile roofs, a low church steeple, and a narrow cobbled lane between them; warm, soft light casts long, thin shadows and brings out the rough texture of stone and the matte clay tiles. Several groups of birds are flying across the pale sky, each flock is shifting shape as birds are soaring, banking, wheeling, splitting off, then rejoining-some individuals are gliding on outstretched wings while others are beating rapidly to gain altitude; their moving shadows skim across rooftops and the lane below as a group arcs together over the steeple. Soundscape: close, soft flapping of wings layered with overlapping bird calls-higher, quick chirps interspersed with occasional low caws-while a single distant church bell tolls slowly once, a light wind is rustling through poplar leaves, a loose wooden shutter creaks and a clay chimney pot clicks as the breeze passes, all synchronized so the wingbeats and calls rise as the flocks bank and briefly swell, then fall away as they split and move out of frame."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60350", "video_prompt": "In a medium close-up, a man in his late 20s with short dark hair and light stubble is cupping a 3-week-old black and white tuxedo kitten with blue eyes in both hands, wearing a light gray cotton shirt; soft window light is falling across their faces and a warm indoor tone is bathing the scene. He is holding the kitten gently, palms cradling its tiny body as he is stroking the soft downy fur with his thumbs; the kitten is calm and relaxed, blinking slowly, paws tucked against his palms and its tiny pink nose twitching. The man smiles and speaks in a low, warm voice, slow pace, \"Hey little one...\" (he leans in slightly, eyes soft, fabric rustle audible), then the kitten replies with a soft, high mew (quiet, brief) while blinking and beginning a steady, quiet purr that grows slightly louder as it settles against his hands. The man responds in a tender hush, gentle pace, \"You're so small, aren't you?\" as he exhales and brushes his thumb along its back; the kitten emits another faint mew and nuzzles his thumb, purring more audibly. Ambient audio layers are intimate and close: a muted distant street hum through the window, a faint clock tick, soft breathing from the man, light fabric rustle when he shifts, the kitten's small mews and continuous low purr present and clear, and a subtle room reverb to convey a small, quiet indoor space."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60360", "video_prompt": "Style: Tim Burton Style Characters. In a dim, low-lit room a lanky, slightly elongated Santa is standing beside a tall, narrow window, peering at a smartphone held in his gloved hand; he has a narrow pale face, wide dark eyes, a scraggly white beard, and a worn red coat with thin fur trim and long sleeves that brush his knuckles. Soft moonlight is filtering through the window, casting thin shadows across his coat while a small warm desk lamp on the sill gives a muted rim light; a cinematic lens with shallow depth of field is rendering the distant streetlights outside as soft bokeh and subtle film grain. The smartphone screen is glowing with a map full of clustered red dots and tiny car icons showing a traffic jam; he is zooming and tapping the screen, brow furrowing as the map shifts. Room audio is quiet: a faint, steady traffic rumble and occasional car horn from the street below, a soft creak as he shifts his weight, and a delicate notification chime from the phone. In a low, gravelly, slow voice he mutters, \"Forty-two minutes? That's too long,\" then he is tapping to try an alternate route as a neutral, clipped phone assistant voice says, \"Delay ahead: 42 minutes, suggested detour adds ten minutes,\" synchronized with the map redrawing under his thumb; he glances up through the window at the stalled tail lights, exhales, and in a short, resigned tone replies, \"All right, take the detour,\" then tucks the phone into his coat as the distant traffic hum persists and the lamp casts a soft, low shadow across his face."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60390", "video_prompt": "A wide static view of a coastal landscape on a sunny day: the sun is high in the pale sky, casting soft overhead light and subtle shadows across smooth wet sand and scattered pale pebbles while small waves are lapping at the shore and thin white foam is retreating with a gentle hiss as sunlight glints on the ripple tops. As a light breeze is bending the dune grass and a few palm fronds, several seagulls are circling low and calling with short sharp cries, and a distant sailboat is drifting near the horizon with its canvas lightly billowing. The soundscape matches the motion-soft rhythmic surf washing onto sand, the thin hiss of foam receding, wind whispering through the grass, intermittent gull calls, and an occasional soft metallic clink from the sailboat rigging, all underscored by a faint, slow acoustic guitar picking a quiet two-bar motif to add calm warmth to the scene."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60404", "video_prompt": "A medium close-up shows Eveline, a young sorceress in her early 20s with waist-length dark brown hair braided loosely over one shoulder, wearing a moss-green cloak over a simple linen dress and scuffed leather boots, kneeling on soft emerald moss in a serene sun-dappled forest; soft overhead light filters through tall oaks, casting moving leaf shadows and the scene is textured with damp bark, tiny wildflowers, and dewy spiderwebs. Ambient forest sounds-distant birdsong, a gentle breeze rustling leaves, a nearby creek's quiet trickle-form a calm background; her soft footsteps on moss and the quiet inhale of her breath punctuate the space as she is experimenting with magic, tracing a small sigil in the air with a trembling finger while murmuring an incantation, \"Focus... steady,\" (soft, hesitant, low) and tiny golden motes are gathering at her fingertip with a faint bell-like chime and gentle electrical crackle. She is tilting her head, squinting in concentration, then is pushing her palm forward and a warm pale orb of light is lifting from her hand, spinning slowly as leaves and dust begin orbiting it; the orb is emitting a subtle harmonic drone and delicate tinkling SFX as it is stabilizing. Eveline is exhaling sharply, \"It's... it's working,\" (breathless, surprised, slightly high) and she is leaning closer, eyes widening and mouth parting in visible awe while small vines at her feet are curling toward the light. She is straightening with a small, stunned smile, whispering, \"I can do this,\" (quiet, resolute, calm) as the forest ambience swells-a soft wind gust, a brief chorus of birds-and the orb is pulsing once with a warm luminous beat that is bathing her face in soft gold, capturing the brief, clear moment of realization and possibility."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60416", "video_prompt": "Style: cartoon. In a static medium-wide shot, a cartoon girl of about 10 sits on a smooth gray stone ledge at the base of a narrow waterfall, legs dangling and hands resting on the rock; she wears a teal hoodie and denim shorts, brown shoulder-length hair with a small side braid, and a soft, contented expression as she looks out at the view of a tree-lined valley beyond. Soft midday light filters through green leaves, the waterfall is drawn with clear blue-green water and white froth, and a fine mist makes tiny droplets bead on her knees; a light breeze moves her braid and the hem of her hoodie. The water is continuously audible as a steady, low roar; higher, crisp splashes hit the stones and a few bright bird calls and a faint rustle of leaves sit beneath the falls. She shifts her weight, tucks her braid behind her ear, blinks, and exhales slowly as the mist lands on her face (subtle wet SFX). Girl (soft, reflective, slow): \"It's so peaceful here...\" (her voice is intimate, slightly breathy; waterfall audio ducks gently while she speaks). She smiles, presses her palms to the stone, and says with a small laugh, Girl (light, quick): \"Maybe I should come back with a sketchbook.\" After her last word the waterfall volume returns to full, droplets patter, and the scene holds with distant birds and steady water ambience."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60454", "video_prompt": "In a static medium-wide shot, a woman in her late 20s with wind-tousled dark hair is sitting on a smooth driftwood log at the sandy shore, wearing a light gray knit sweater and worn jeans, facing the sea as soft late-afternoon light washes the scene and low sun glances off the wet sand. Small waves are lapping and pulling at the shoreline, seafoam is pulsing around scattered pebbles, and a cool breeze is ruffling her hair and sweater; distant gull calls punctuate the air and a low foghorn is sounding far off. She is watching the horizon, fingers lightly tapping the log, then draws a slow breath and says in a quiet, steady voice, \"Calmer today, huh?\" She glances down at a phone in her lap; an off-screen voice, warm and slightly amused, replies quickly, \"Yeah - you found the quiet spot.\" She lets out a small smile, tucks hair behind her ear, answers softly, \"I needed this,\" and turns her gaze back to the sea while the rhythmic hiss of waves and gentle wind continue under the brief exchange, with occasional seabird calls and a faint distant dog bark adding texture to the seaside ambience."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60489", "video_prompt": "In a medium close-up, Alexander Lukashenko, a white man in his early 70s with short gray hair and a clean-shaven face, wearing a dark navy suit and a striped tie, sits at a low podium against an out-of-focus backdrop showing a red-and-green flag; soft overhead light and a subtle rim light outline his shoulders as the scene focuses on his face. He is smiling, then leans forward slightly, then tilts his head back and throws it back as he bursts into a short, throaty laugh-his shoulders shaking and eyes crinkling-while one hand comes up briefly to his mouth and then drops to the podium. The audio layer presents a close, gravelly laugh in the foreground, layered with light applause and murmured reactions from an off-screen audience, the faint rustle of papers, and a soft microphone rustle; as the laugh begins he utters one dry, measured line in a low, even tone, \"Well, that's unexpected,\" (low, dry, slow), then follows with a quick, breathy chuckle (deep, warm) that overlaps the room noise. The camera remains steady in the medium close-up, capturing the sequence of smile, voiced remark, and sudden laugh while ambient sounds - soft clapping, hushed voices, a single camera shutter click - sit behind the laugh and then settle as the laughter fades."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60498", "video_prompt": "A static close-up frames a Spider-Man action figure standing on a worn wooden shelf, painted red and blue suit with glossy plastic highlights and visible articulated joints; soft overhead light casts mild reflections across the molded webbing and a shallow depth of field blurs the background books. He is slowly tilting his head to the left as his white eye lenses curve upward into a clear smiling expression, then he raises his right hand in a short, friendly wave while a crisp plastic joint click and a faint squeak register with each motion. Room ambience carries a distant city hum and soft indoor air movement; as his lenses tighten into the smile a brief, gentle synth twinkle accents the moment. Spider-Man (playful, slightly high-pitched, quick) says, \"Hey-ready?\" as he lifts his hand, the voice timed to the click of the wrist joint; he pauses, leans forward a hair, and the painted smile seems to deepen with a tiny plastic rasp. Spider-Man (warm, amused, medium pace) follows, \"You always are, right?\" while he tilts his head back and his shoulder rotates with a soft mechanical click. Spider-Man (confident, brisk, low) finishes, \"Okay-on three,\" as he nods once and the light catches a small scuff on the painted chest; a short, bright chime punctuates the final grin and the ambient city hum continues under the closing moment."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60508", "video_prompt": "A matte black Porsche 911 is cruising along a narrow, leaf-strewn forest road in the evening, its low headlights cutting through dim blue-gray dusk while long cool shadows from pine and birch stretch across the pavement; soft, warm red taillights smear over wet leaves as the car moves, and small sprays of leaves lift and scatter behind the rear tires. The engine is emitting a low, measured purr that rises briefly as the driver downshifts while negotiating a gentle curve, tires softly crunching on damp leaves and loose gravel beneath the chassis; occasional low branches brush the hood and send a faint metallic whisper along the bodywork. Ambient sound is a quiet forest layer-wind rustling needles and leaves in the canopy, a distant bird call, and the echo of the engine bouncing between tree trunks-then the subtle click of the turn signal and the whisper of airflow as the Porsche arcs past, remaining alone in the calm evening woods."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60528", "video_prompt": "A quaint village nestled among rolling hills and surrounded by peaceful trees is bathed in a sky of orange and pink as the sun begins to set, soft late-afternoon light warming stone cottages and small gardens while gentle rustling of leaves and the sweet melodies of birds fill the air. After two seconds, the screen slowly zooms in, then over the next three seconds the camera slowly zooms in and pans, offering a picturesque view of the clustered homes before gracefully pointing toward a nearby pond that is shimmering in the warm sunlight; the pond's surface mirrors the colored sky and surrounding trees with glinting highlights moving across the water. As the focus narrows on the pond, three vibrant orange fishes are swimming in graceful arcs just below the surface, their scales glistening as sunlight catches them while they glide in small, coordinated loops and send soft ripples outward. Audio is layered to match the motion: continuous leaf rustle and clear bird song in the foreground, then gentle water lapping and faint, synchronized splashes as each fish breaks the surface of a ripple, keeping the overall soundscape calm and harmonious with the village remaining quiet in the background."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60532", "video_prompt": "A medium close-up of a young woman in her late 20s with short black hair tucked behind her ear, wearing a light gray blouse, sitting at a small wooden table at a street-side food stall as soft late-afternoon light falls across a glossy ceramic bowl of mie ayam-yellow noodles piled with shredded braised chicken, chopped scallions and a few green vegetables, dark sauce pooling at the bottom; she is lifting a tangle of noodles with wooden chopsticks, twirling them once, then slurping them into her mouth while her free hand is scooping broth and chicken with a metal spoon, her eyes closing briefly and a small smile forming. Ambient street audio is present-distant traffic hum, a motorbike idling, low vendor chatter and occasional clink of dishes-while close SFX highlight wooden chopsticks tapping porcelain, a wet noodle slurp, the soft clink of spoon on bowl and a quiet breath; a gentle instrumental with soft percussive tones plays under the ambient mix. As she sets the chopsticks down she says in a warm, measured voice, \"Looks comforting,\" pauses and glances around, then in a soft, content voice adds, \"Just what I needed,\" and reaches for another bite."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60538", "video_prompt": "A static medium-wide shot frames a low, moss-covered rock ledge and the cool pool below, soft afternoon light filtering through a leafy canopy and casting dappled patches across dark green water; the rock is slick with thin rivulets and small, pale leaves cluster at the edge. A small, smooth pebble is teetering on the lip, then tumbles and plunges into the cool pool below, breaking the mirror surface and sending concentric ripples outward while tiny droplets arc upward and scatter. Under the surface a brief ring of bubbles is rising and dissolving, and as the ripple reaches floating leaves they bob and shift. Audio: an immediate, sharp splash on impact, followed by spreading, gentle slaps of water against stone, a soft underwater gurgle as bubbles release, faint steady trickle from the rock face, distant birds calling and a low insect hum; after the initial splash the water calms and small droplets patter on leaves while the echo of the impact fades."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60546", "video_prompt": "A happy child of about eight years old with short curly hair and subtle freckles is sitting cross-legged on a small red picnic blanket in a sunlit backyard, wearing a striped t-shirt and denim shorts, holding a small soprano ukulele with a light wood grain and glossy finish; warm late-afternoon light is casting soft shadows and a few leaves are drifting in a light breeze. The child is smiling and is strumming a bright, bouncy chord progression with the right hand while the left hand is forming a simple C-to-G chord change, the ukulele strings ringing with clear plucked tones; the child is gently swaying and bobbing their head as they sing one short, cheerful line in a light, singing voice, Kid (singing, bright, slightly high): \"Sunshine on my day, play along, hey!\"-they finish the phrase with a quick, playful flourish across the strings, then laugh and look up, Kid (delighted, breathy): \"Again!\" as they tap the uke body with their thumb and launch into another upbeat strum. Audio includes close, intimate ukulele sound with crisp string attack and soft resonance, the child's voice layered slightly forward, faint backyard ambience-distant birdsong, a soft breeze through leaves, a muted lawnmower far off-and the small SFX of fingers on wood and a quick giggle; everything is synchronized so the vocal lines match the visible strums and the final tap coincides with the child's delighted exclamation."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60571", "video_prompt": "In a wide, low-angle shot from the highway shoulder, three super-fast sports cars are racing side-by-side along a wet night highway, a red low-slung coupe with a white racing stripe on the left is surging forward, a matte black aggressive supercar in the center is pulling slightly ahead with sharp LED accents cutting through light mist, and a metallic blue targa on the right is darting forward; glossy body panels catch and reflect neon from the big city glowing in the distance, soft sodium streetlamps pool on the slick asphalt, and heat shimmer ripples above each exhaust. As they accelerate, engine roars dominate the mix- the red coupe emits a high, razor-edge rev, the black car answers with a deep, throaty growl, and the blue car adds a fast turbo whistle-interleaved with rapid upshifts and short throttle blips; tires hiss and briefly squeal during tight lane corrections, small pebbles crack against wheel wells, and a sharp whoosh of wind whistles past side mirrors. Ahead, the distant city skyline of clustered towers and billboard neon provides a low, constant urban hum with faint car horns and a muted subway rumble beneath, while the Doppler shift bends each engine pitch as the cars edge forward and then tuck in, motion blur streaking headlights and building lights to emphasize raw speed for the five-second burst."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60572", "video_prompt": "Style: hyper realism 4k. A static tight close-up of a single candle sitting in an aged brass candlestick on a dark wooden surface, the creamy white wax is melting into a glossy pool that is slowly running down the stem while the blackened wick is holding a steady amber flame; 4k detail reveals the fine char texture of the wick, tiny beads of molten wax, and subtle fingerprints and patina on the brass. Soft warm light from the flame is casting gentle reflections on the candlestick and subtle, narrow shadows on the wood as a faint draft moves through the frame, making the flame flicker and lean; the wick crackles quietly and a thin metallic spark flicks at the tip as the flame thins, then a short airy whoosh is heard exactly as the flame collapses and goes out. A slender plume of blue-gray smoke is rising in a slow spiral from the still-glowing ember, the glowing tip dimming to a dull black nub while a few droplets of settling wax produce soft, muted clicks; ambient room hush underlies the moment with a low, unobtrusive room tone and a distant faint creak, and the residual sound of the extinguish - a soft breath-like hiss and the last faint pop - lingers as the scene holds on the quiet candlestick and the smoke disperses."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60589", "video_prompt": "A tight close-up of a brown-gray tabby cat with distinct black stripes and a faint white chin, lying on a soft cream knit blanket under soft side light from a nearby window; the cat blinks slowly, then leans in and presses its cheek and forehead into the blanket in a series of gentle, deliberate nuzzles while its whiskers brush the fabric and its ears flick slightly. Its green eyes remain half-closed and its shoulders shift forward as it rubs, then tilts its head and repeats another short, purposeful rub; the fur has a soft, slightly glossy texture and small tufts move with each motion. Ambient indoor room noise is quiet-a low, steady HVAC hum and distant muted street sound-then a low, steady purr begins as the cat first nuzzles, rising into a warm, continuous rumble synchronized with the rubbing. Each contact is accompanied by a soft fabric rustle and a faint breathy exhale; after the final nuzzle the cat releases and emits a brief, contented trill before settling, the purr continuing gently under the room ambience."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60601", "video_prompt": "In a medium close-up, a couple in their late 20s is sitting across a small round wooden table in a cozy cafe, the woman with shoulder-length dark hair and a cream sweater holding a white ceramic cup, the man with short brown hair, light stubble, and a navy button-down resting his forearms on the table; soft warm yellow pendant lights overhead are reflecting gently on their faces and creating small highlights on the cup and the glass vase between them, while the background is softly blurred with other patrons and warm wood tones. Low indie acoustic music is playing quietly from the cafe speakers as steady ambient murmur, occasional silverware clinks, and a distant espresso machine hiss form a continuous soundbed. She is smiling and laughing, tucks a strand of hair behind her ear, and says in a warm, laugh-tinged voice, \"I'm really glad we came out tonight,\" with a short pause and a quick smile toward him; a soft clink occurs as he sets his cup down. He is smiling back, leaning in slightly, and replies in a soft, even voice, \"Me too-it's been nice,\" pausing to meet her eyes while a chair scrapes gently in the background. She is glancing down at her cup, then looking up with a brighter smile and says in a quiet, upbeat voice, \"Let's do this again,\" as the music swells a fraction and the cafe ambience continues beneath their voices, leaving them both smiling and holding eye contact."}
|
||||
{"id": "vidprom_semantic_unique_gpt-5-mini-2025-08-07_60632", "video_prompt": "A wide view of a lush forest of mixed hardwoods and fruit-bearing trees rising beside a calm lake, soft late-afternoon light filtering through green leaves and dappling mossy trunks. Low branches are heavy with red apples and yellow pears, their glossy skins catching the light as a gentle breeze is moving through the canopy-leaves are rustling and small clusters of fruit are swaying while a few water droplets bead on leaf edges. At the lake edge, reeds are bending slightly and the grey-blue surface is forming narrow ripples where the wind touches it; a dragonfly is skimming the water and a small fish is breaking the surface with a soft plop, sending a brief circular ripple outward. Ambient audio is layered and synced with action: the wind is creating a steady soft rustle through leaves, clear birdsong (quick sparrow-like chirps and a distant thrush) is punctuating the treetops, a low insect buzz is underscoring the scene, and gentle water lapping is meeting the pebbled shore; when a fruit brushes a branch a muted thud and the faint crunch of leaf litter register. The scene feels tranquil but active, natural motions continuing throughout the short clip."}
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg width="252" height="105" viewBox="0 0 252 105" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM100.768 13.1217L87.7028 29.4852H103.143L100.768 13.1217Z" fill="#356CFF"/>
|
||||
<path d="M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM109.081 90.697L116.802 65.8487C116.802 65.8487 120.959 65.8487 132.242 65.8487C143.525 65.8487 137.586 78.5759 135.211 84.0304C133.307 88.4021 127.491 90.697 122.74 90.697C117.989 90.697 109.081 90.697 109.081 90.697Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944H159.747C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273L125.188 48.273L124 37.97L147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852H131.836C120.142 29.4852 125.897 1.00043 141.337 1.00043L173.188 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM237.948 77.9692C239.984 70.6965 240.917 65.242 228.446 65.242C215.975 65.242 211.818 71.9087 210.037 77.9692C208.255 84.0298 208.255 91.3025 219.538 91.3025C230.821 91.3025 235.911 85.2419 237.948 77.9692Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944M173.188 1.00056C173.188 1.00056 156.777 1.00043 141.337 1.00043M173.188 1.00056L141.337 1.00043M141.337 20.3944C146.088 20.3944 150.839 20.3944 159.747 20.3944M141.337 20.3944H159.747M159.747 20.3944C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273M148.463 48.273C139.556 48.273 125.188 48.273 125.188 48.273M148.463 48.273L125.188 48.273M125.188 48.273L124 37.97M124 37.97C124 37.97 141.931 37.97 147.87 37.97M124 37.97L147.87 37.97M147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852M151.433 29.4852C146.682 29.4852 138.962 29.4852 131.836 29.4852M151.433 29.4852H131.836M131.836 29.4852C120.142 29.4852 125.897 1.00043 141.337 1.00043M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057ZM96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM87.7028 29.4852L100.768 13.1217L103.143 29.4852H87.7028ZM89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457ZM108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM116.802 65.8487L109.081 90.697C109.081 90.697 117.989 90.697 122.74 90.697C127.491 90.697 133.307 88.4021 135.211 84.0304C137.586 78.5759 143.525 65.8487 132.242 65.8487C120.959 65.8487 116.802 65.8487 116.802 65.8487ZM179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056ZM161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457ZM230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM228.446 65.242C240.917 65.242 239.984 70.6965 237.948 77.9692C235.911 85.2419 230.821 91.3025 219.538 91.3025C208.255 91.3025 208.255 84.0298 210.037 77.9692C211.818 71.9087 215.975 65.242 228.446 65.242Z" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M15.2524 55.5451L21.191 100.999L24.7541 100.999L18.8156 55.5451L15.2524 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 55.5451L14.065 100.999L15.2527 100.999L9.31417 55.5451L8.12646 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 55.5451L6.93853 100.999L7.53239 100.999L1.59385 55.5451L1 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M15.2524 48.2724L30.0988 1H33.6619L18.8156 48.2724H15.2524Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 48.2724L22.9728 1H24.1605L9.31417 48.2724H8.12646Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 48.2724L15.8463 1H16.4402L1.59385 48.2724H1Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M85.3271 55.5457H67.5116L87 12.7363L44.3513 68.2729H58.6038L43.1636 101L85.3271 55.5457Z" fill="#FDC717" stroke="#FDC717" stroke-width="1.18771" stroke-miterlimit="16"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="252" height="105" viewBox="0 0 252 105" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM100.768 13.1217L87.7028 29.4852H103.143L100.768 13.1217Z" fill="#356CFF"/>
|
||||
<path d="M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM109.081 90.697L116.802 65.8487C116.802 65.8487 120.959 65.8487 132.242 65.8487C143.525 65.8487 137.586 78.5759 135.211 84.0304C133.307 88.4021 127.491 90.697 122.74 90.697C117.989 90.697 109.081 90.697 109.081 90.697Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944H159.747C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273L125.188 48.273L124 37.97L147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852H131.836C120.142 29.4852 125.897 1.00043 141.337 1.00043L173.188 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM237.948 77.9692C239.984 70.6965 240.917 65.242 228.446 65.242C215.975 65.242 211.818 71.9087 210.037 77.9692C208.255 84.0298 208.255 91.3025 219.538 91.3025C230.821 91.3025 235.911 85.2419 237.948 77.9692Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944M173.188 1.00056C173.188 1.00056 156.777 1.00043 141.337 1.00043M173.188 1.00056L141.337 1.00043M141.337 20.3944C146.088 20.3944 150.839 20.3944 159.747 20.3944M141.337 20.3944H159.747M159.747 20.3944C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273M148.463 48.273C139.556 48.273 125.188 48.273 125.188 48.273M148.463 48.273L125.188 48.273M125.188 48.273L124 37.97M124 37.97C124 37.97 141.931 37.97 147.87 37.97M124 37.97L147.87 37.97M147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852M151.433 29.4852C146.682 29.4852 138.962 29.4852 131.836 29.4852M151.433 29.4852H131.836M131.836 29.4852C120.142 29.4852 125.897 1.00043 141.337 1.00043M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057ZM96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM87.7028 29.4852L100.768 13.1217L103.143 29.4852H87.7028ZM89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457ZM108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM116.802 65.8487L109.081 90.697C109.081 90.697 117.989 90.697 122.74 90.697C127.491 90.697 133.307 88.4021 135.211 84.0304C137.586 78.5759 143.525 65.8487 132.242 65.8487C120.959 65.8487 116.802 65.8487 116.802 65.8487ZM179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056ZM161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457ZM230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM228.446 65.242C240.917 65.242 239.984 70.6965 237.948 77.9692C235.911 85.2419 230.821 91.3025 219.538 91.3025C208.255 91.3025 208.255 84.0298 210.037 77.9692C211.818 71.9087 215.975 65.242 228.446 65.242Z" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M15.2524 55.5451L21.191 100.999L24.7541 100.999L18.8156 55.5451L15.2524 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 55.5451L14.065 100.999L15.2527 100.999L9.31417 55.5451L8.12646 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 55.5451L6.93853 100.999L7.53239 100.999L1.59385 55.5451L1 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M15.2524 48.2724L30.0988 1H33.6619L18.8156 48.2724H15.2524Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 48.2724L22.9728 1H24.1605L9.31417 48.2724H8.12646Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 48.2724L15.8463 1H16.4402L1.59385 48.2724H1Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M85.3271 55.5457H67.5116L87 12.7363L44.3513 68.2729H58.6038L43.1636 101L85.3271 55.5457Z" fill="#FDC717" stroke="#FDC717" stroke-width="1.18771" stroke-miterlimit="16"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="160" height="93" viewBox="0 0 160 93" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.8511 91.66L57.6319 1.86368H64.5394L35.7585 91.66H28.8511Z" fill="#356CFF" stroke="#356CFF" stroke-width="2.30244"/>
|
||||
<path d="M15.0376 91.66L43.8185 1.86368H46.1209L17.3401 91.66H15.0376Z" fill="#356CFF" stroke="#356CFF" stroke-width="2.30244"/>
|
||||
<path d="M1.22217 91.66L30.003 1.86366H31.1543L2.3734 91.66H1.22217Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.15122"/>
|
||||
<path d="M71.4465 1.86483L42.666 91.6599H69.144L78.3538 58.2746H123.251L129.007 39.855H84.1099L89.866 22.5868H152.032L157.788 1.86483H71.4465Z" fill="#356CFF" stroke="#356CFF" stroke-width="2.30244"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 691 B |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="251" height="102" viewBox="0 0 251 102" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M88.8911 54.0412H100.768L87.1095 98.5802H74.0447L88.8911 54.0412Z" fill="#0C0D6F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M95.4234 0.594433H112.051L117.99 46.915H104.331L103.144 38.601H79.3894L66.9185 54.0412H84.7341L42.5705 98.5802H27.7242L21.7856 54.0412H33.0689L37.8197 89.0786L58.0107 66.5121H43.7582L95.4234 0.594433ZM100.174 12.4715L87.1095 28.5055H102.55L100.174 12.4715Z" fill="#0C0D6F"/>
|
||||
<path d="M36.632 0.594433L21.7856 46.915H35.4443L40.1951 29.6932H63.3554L66.3246 20.1916H43.1644L46.1336 11.2838H78.2017L81.171 0.594433H36.632Z" fill="#0C0D6F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M107.894 54.0412L93.6419 98.5802C93.6419 98.5802 104.925 98.5802 120.365 98.5802C135.805 98.5802 143.485 90.7544 147.683 77.7954C151.839 66.2601 152.433 54.0412 135.805 54.0412C119.178 54.0412 107.894 54.0412 107.894 54.0412ZM108.488 88.4847L116.208 64.1367C116.208 64.1367 120.365 64.1367 131.648 64.1367C142.932 64.1367 136.993 76.6077 134.618 81.9523C132.714 86.236 126.898 88.4847 122.147 88.4847C117.396 88.4847 108.488 88.4847 108.488 88.4847Z" fill="#0C0D6F"/>
|
||||
<path d="M172.031 0.593872L168.467 11.2838C168.467 11.2838 148.605 11.2834 141.744 11.2837C134.883 11.284 135.805 19.5977 140.556 19.5977H158.966C167.874 19.5977 166.18 32.6255 163.123 37.4133C159.686 42.7941 156.59 46.915 147.683 46.915H122.741L121.553 36.8195H147.089C153.027 36.8195 155.403 28.5055 150.652 28.5055H131.055C119.361 28.5055 125.116 0.594307 140.556 0.594307L172.031 0.593872Z" fill="#0C0D6F"/>
|
||||
<path d="M177.375 0.593872L173.812 11.2838H190.44L179.157 46.915H191.628L202.911 11.2838L218.351 11.2837L222.508 0.594307L177.375 0.593872Z" fill="#0C0D6F"/>
|
||||
<path d="M160.747 54.0412H201.723L198.754 64.1367H169.061L166.686 71.8563H193.409L191.034 80.7641H163.717L161.341 88.4847H191.034L188.658 98.5802H145.901L160.747 54.0412Z" fill="#0C0D6F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M230.228 53.4468C254.576 53.4468 251.183 65.3239 248.638 76.0132C246.092 86.7026 240.324 98.5802 217.163 98.5802C194.003 98.5802 195.012 86.7026 198.754 76.0132C202.495 65.3239 205.88 53.4468 230.228 53.4468ZM237.354 76.0132C239.391 68.887 240.324 63.5423 227.853 63.5423C215.382 63.5423 211.225 70.0747 209.443 76.0132C207.662 81.9518 207.662 89.078 218.945 89.078C230.228 89.078 235.318 83.1395 237.354 76.0132Z" fill="#0C0D6F"/>
|
||||
<path d="M172.031 0.593872L168.467 11.2838C168.467 11.2838 148.605 11.2834 141.744 11.2837C134.883 11.284 135.805 19.5977 140.556 19.5977M172.031 0.593872C172.031 0.593872 155.996 0.594307 140.556 0.594307M172.031 0.593872L140.556 0.594307M140.556 19.5977C145.307 19.5977 150.058 19.5977 158.966 19.5977M140.556 19.5977H158.966M158.966 19.5977C167.874 19.5977 166.18 32.6255 163.123 37.4133C159.686 42.7941 156.59 46.915 147.683 46.915M147.683 46.915C138.775 46.915 122.741 46.915 122.741 46.915M147.683 46.915H122.741M122.741 46.915L121.553 36.8195M121.553 36.8195C121.553 36.8195 141.15 36.8195 147.089 36.8195M121.553 36.8195H147.089M147.089 36.8195C153.027 36.8195 155.403 28.5055 150.652 28.5055M150.652 28.5055C145.901 28.5055 138.181 28.5055 131.055 28.5055M150.652 28.5055H131.055M131.055 28.5055C119.361 28.5055 125.116 0.594307 140.556 0.594307M36.632 0.594433L21.7856 46.915H35.4443L40.1951 29.6932H63.3554L66.3246 20.1916H43.1644L46.1336 11.2838H78.2017L81.171 0.594433H36.632ZM95.4234 0.594433H112.051L117.99 46.915H104.331L103.144 38.601H79.3894L66.9185 54.0412H84.7341L42.5705 98.5802H27.7242L21.7856 54.0412H33.0689L37.8197 89.0786L58.0107 66.5121H43.7582L95.4234 0.594433ZM87.1095 28.5055L100.174 12.4715L102.55 28.5055H87.1095ZM88.8911 54.0412H100.768L87.1095 98.5802H74.0447L88.8911 54.0412ZM107.894 54.0412L93.6419 98.5802C93.6419 98.5802 104.925 98.5802 120.365 98.5802C135.805 98.5802 143.485 90.7544 147.683 77.7954C151.839 66.2601 152.433 54.0412 135.805 54.0412C119.178 54.0412 107.894 54.0412 107.894 54.0412ZM116.208 64.1367L108.488 88.4847C108.488 88.4847 117.396 88.4847 122.147 88.4847C126.898 88.4847 132.714 86.236 134.618 81.9523C136.993 76.6077 142.932 64.1367 131.648 64.1367C120.365 64.1367 116.208 64.1367 116.208 64.1367ZM177.375 0.593872L173.812 11.2838H190.44L179.157 46.915H191.628L202.911 11.2838L218.351 11.2837L222.508 0.594307L177.375 0.593872ZM160.747 54.0412H201.723L198.754 64.1367H169.061L166.686 71.8563H193.409L191.034 80.7641H163.717L161.341 88.4847H191.034L188.658 98.5802H145.901L160.747 54.0412ZM230.228 53.4468C254.576 53.4468 251.183 65.3239 248.638 76.0132C246.092 86.7026 240.324 98.5802 217.163 98.5802C194.003 98.5802 195.012 86.7026 198.754 76.0132C202.495 65.3239 205.88 53.4468 230.228 53.4468ZM227.853 63.5423C240.324 63.5423 239.391 68.887 237.354 76.0132C235.318 83.1395 230.228 89.078 218.945 89.078C207.662 89.078 207.662 81.9518 209.443 76.0132C211.225 70.0747 215.382 63.5423 227.853 63.5423Z" stroke="#0C0D6F" stroke-width="1.18771"/>
|
||||
<path d="M14.6592 54.0407L20.5977 98.5797L24.1608 98.5797L18.2223 54.0406L14.6592 54.0407Z" fill="#0C0D6F" stroke="#0C0D6F" stroke-width="1.18771"/>
|
||||
<path d="M7.5332 54.0407L13.4717 98.5797L14.6594 98.5797L8.72091 54.0406L7.5332 54.0407Z" fill="#0C0D6F" stroke="#0C0D6F" stroke-width="1.18771"/>
|
||||
<path d="M0.406738 54.0406L6.34527 98.5797L6.93912 98.5797L1.00059 54.0407L0.406738 54.0406Z" fill="#0C0D6F" stroke="#0C0D6F" stroke-width="0.593853"/>
|
||||
<path d="M14.6592 46.9144L29.5055 0.593872H33.0686L18.2223 46.9144H14.6592Z" fill="#0C0D6F" stroke="#0C0D6F" stroke-width="1.18771"/>
|
||||
<path d="M7.5332 46.9144L22.3795 0.593872H23.5672L8.72091 46.9144H7.5332Z" fill="#0C0D6F" stroke="#0C0D6F" stroke-width="1.18771"/>
|
||||
<path d="M0.406738 46.9144L15.2531 0.593872H15.8469L1.00059 46.9144H0.406738Z" fill="#0C0D6F" stroke="#0C0D6F" stroke-width="0.593853"/>
|
||||
<path d="M84.7339 54.0413H66.9183L100.174 12.4709L43.758 66.5122H58.0105L42.5703 98.5803L84.7339 54.0413Z" fill="#FDC717" stroke="#FDC717" stroke-width="1.18771" stroke-miterlimit="16"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="252" height="105" viewBox="0 0 252 105" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM100.768 13.1217L87.7028 29.4852H103.143L100.768 13.1217Z" fill="#356CFF"/>
|
||||
<path d="M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM109.081 90.697L116.802 65.8487C116.802 65.8487 120.959 65.8487 132.242 65.8487C143.525 65.8487 137.586 78.5759 135.211 84.0304C133.307 88.4021 127.491 90.697 122.74 90.697C117.989 90.697 109.081 90.697 109.081 90.697Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944H159.747C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273L125.188 48.273L124 37.97L147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852H131.836C120.142 29.4852 125.897 1.00043 141.337 1.00043L173.188 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056Z" fill="#356CFF"/>
|
||||
<path d="M161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457Z" fill="#356CFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM237.948 77.9692C239.984 70.6965 240.917 65.242 228.446 65.242C215.975 65.242 211.818 71.9087 210.037 77.9692C208.255 84.0298 208.255 91.3025 219.538 91.3025C230.821 91.3025 235.911 85.2419 237.948 77.9692Z" fill="#356CFF"/>
|
||||
<path d="M173.188 1.00056L168.625 11.9096C168.625 11.9096 149.386 11.9092 142.525 11.9095C135.664 11.9098 136.586 20.3944 141.337 20.3944M173.188 1.00056C173.188 1.00056 156.777 1.00043 141.337 1.00043M173.188 1.00056L141.337 1.00043M141.337 20.3944C146.088 20.3944 150.839 20.3944 159.747 20.3944M141.337 20.3944H159.747M159.747 20.3944C168.654 20.3944 166.961 33.6899 163.904 38.5761C160.467 44.0675 157.371 48.273 148.463 48.273M148.463 48.273C139.556 48.273 125.188 48.273 125.188 48.273M148.463 48.273L125.188 48.273M125.188 48.273L124 37.97M124 37.97C124 37.97 141.931 37.97 147.87 37.97M124 37.97L147.87 37.97M147.87 37.97C153.808 37.97 156.184 29.4852 151.433 29.4852M151.433 29.4852C146.682 29.4852 138.962 29.4852 131.836 29.4852M151.433 29.4852H131.836M131.836 29.4852C120.142 29.4852 125.897 1.00043 141.337 1.00043M37.2252 1.00057L22.3789 48.273H36.0375L40.7884 30.6974L62.6727 30.6974L69.6727 21.0005L43.7576 21.0004L46.7269 11.9096L77.6727 11.9096L86 1.00057L37.2252 1.00057ZM96.0167 1.00057H112.645L118.583 48.273H104.924L103.737 39.7882H79.9827L67.5117 55.5457H85.3273L43.1638 101H28.3174L22.3789 55.5457H33.6621L38.4129 91.3031L58.604 68.2729H44.3515L96.0167 1.00057ZM87.7028 29.4852L100.768 13.1217L103.143 29.4852H87.7028ZM89.4843 55.5457H101.361L87.7028 101H74.638L89.4843 55.5457ZM108.488 55.5457L94.2351 101C94.2351 101 105.518 101 120.959 101C136.399 101 144.078 93.0133 148.276 79.788C152.432 68.0157 153.027 55.5457 136.399 55.5457C119.771 55.5457 108.488 55.5457 108.488 55.5457ZM116.802 65.8487L109.081 90.697C109.081 90.697 117.989 90.697 122.74 90.697C127.491 90.697 133.307 88.4021 135.211 84.0304C137.586 78.5759 143.525 65.8487 132.242 65.8487C120.959 65.8487 116.802 65.8487 116.802 65.8487ZM179.938 1.00056L175.688 11.9096L191.221 11.9096L179.938 48.273H192.409L203.692 11.9096L219.132 11.9095L223.289 1.00043L179.938 1.00056ZM161.341 55.5457H202.845L198.5 65.8487H169.654L167.279 73.7268H188.5L184.749 82.8177H164.31L161.934 90.697H190.251L186.624 101H146.494L161.341 55.5457ZM230.821 54.9391C255.169 54.9391 251.776 67.0602 249.231 77.9692C246.686 88.8783 240.917 101 217.757 101C194.596 101 195.606 88.8783 199.347 77.9692C203.089 67.0602 206.473 54.9391 230.821 54.9391ZM228.446 65.242C240.917 65.242 239.984 70.6965 237.948 77.9692C235.911 85.2419 230.821 91.3025 219.538 91.3025C208.255 91.3025 208.255 84.0298 210.037 77.9692C211.818 71.9087 215.975 65.242 228.446 65.242Z" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M15.2524 55.5451L21.191 100.999L24.7541 100.999L18.8156 55.5451L15.2524 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 55.5451L14.065 100.999L15.2527 100.999L9.31417 55.5451L8.12646 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 55.5451L6.93853 100.999L7.53239 100.999L1.59385 55.5451L1 55.5451Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M15.2524 48.2724L30.0988 1H33.6619L18.8156 48.2724H15.2524Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M8.12646 48.2724L22.9728 1H24.1605L9.31417 48.2724H8.12646Z" fill="#356CFF" stroke="#356CFF" stroke-width="1.18771"/>
|
||||
<path d="M1 48.2724L15.8463 1H16.4402L1.59385 48.2724H1Z" fill="#356CFF" stroke="#356CFF" stroke-width="0.593853"/>
|
||||
<path d="M85.3271 55.5457H67.5116L87 12.7363L44.3513 68.2729H58.6038L43.1636 101L85.3271 55.5457Z" fill="#FDC717" stroke="#FDC717" stroke-width="1.18771" stroke-miterlimit="16"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,574 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAvPipeline } from './avPipeline';
|
||||
|
||||
type MockMediaSourceCtor = {
|
||||
new (): any;
|
||||
isTypeSupported: (mime: string) => boolean;
|
||||
};
|
||||
|
||||
type MockMediaSourceReadyState = 'closed' | 'open' | 'ended';
|
||||
|
||||
function createBufferedRange(start: number, end: number): any {
|
||||
return {
|
||||
length: 1,
|
||||
start(index: number): number {
|
||||
if (index !== 0) {
|
||||
throw new RangeError('invalid buffered range index');
|
||||
}
|
||||
return start;
|
||||
},
|
||||
end(index: number): number {
|
||||
if (index !== 0) {
|
||||
throw new RangeError('invalid buffered range index');
|
||||
}
|
||||
return end;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeVideo(): any {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
const video: any = {
|
||||
currentTime: 0,
|
||||
buffered: createBufferedRange(0, 0),
|
||||
paused: true,
|
||||
disableRemotePlayback: false,
|
||||
addEventListener(type: string, listener: () => void): void {
|
||||
const next = listeners.get(type) || [];
|
||||
next.push(listener);
|
||||
listeners.set(type, next);
|
||||
},
|
||||
removeEventListener(type: string, listener: () => void): void {
|
||||
const next = (listeners.get(type) || []).filter((item) => item !== listener);
|
||||
listeners.set(type, next);
|
||||
},
|
||||
dispatch(type: string): void {
|
||||
for (const listener of listeners.get(type) || []) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
play: vi.fn(() => {
|
||||
video.paused = false;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
video.paused = true;
|
||||
}),
|
||||
load: vi.fn(() => {}),
|
||||
removeAttribute: vi.fn(() => {}),
|
||||
};
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
const fakeVideo = createFakeVideo();
|
||||
|
||||
function setAppleMobileNavigator(): void {
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
|
||||
);
|
||||
vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('iPhone');
|
||||
vi.spyOn(window.navigator, 'maxTouchPoints', 'get').mockReturnValue(5);
|
||||
}
|
||||
|
||||
function setNonAppleNavigator(): void {
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
||||
'Mozilla/5.0 (X11; Linux x86_64)',
|
||||
);
|
||||
vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('Linux x86_64');
|
||||
vi.spyOn(window.navigator, 'maxTouchPoints', 'get').mockReturnValue(0);
|
||||
}
|
||||
|
||||
function setAndroidChromeNavigator(): void {
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36',
|
||||
);
|
||||
vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('Linux armv8l');
|
||||
vi.spyOn(window.navigator, 'maxTouchPoints', 'get').mockReturnValue(5);
|
||||
}
|
||||
|
||||
function overrideMediaSourceApis({
|
||||
mediaSourceCtor,
|
||||
managedMediaSourceCtor,
|
||||
}: {
|
||||
mediaSourceCtor?: MockMediaSourceCtor;
|
||||
managedMediaSourceCtor?: MockMediaSourceCtor;
|
||||
}): () => void {
|
||||
const mediaSourceWindow = window as any;
|
||||
const originalMediaSource = mediaSourceWindow.MediaSource;
|
||||
const originalManagedMediaSource = mediaSourceWindow.ManagedMediaSource;
|
||||
|
||||
if (mediaSourceCtor) {
|
||||
mediaSourceWindow.MediaSource = mediaSourceCtor;
|
||||
} else {
|
||||
delete mediaSourceWindow.MediaSource;
|
||||
}
|
||||
|
||||
if (managedMediaSourceCtor) {
|
||||
mediaSourceWindow.ManagedMediaSource = managedMediaSourceCtor;
|
||||
} else {
|
||||
delete mediaSourceWindow.ManagedMediaSource;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof originalMediaSource === 'undefined') {
|
||||
delete mediaSourceWindow.MediaSource;
|
||||
} else {
|
||||
mediaSourceWindow.MediaSource = originalMediaSource;
|
||||
}
|
||||
|
||||
if (typeof originalManagedMediaSource === 'undefined') {
|
||||
delete mediaSourceWindow.ManagedMediaSource;
|
||||
} else {
|
||||
mediaSourceWindow.ManagedMediaSource = originalManagedMediaSource;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('createAvPipeline', () => {
|
||||
it('uses native playback fallback on Apple mobile when MMS/MSE are unavailable', () => {
|
||||
setAppleMobileNavigator();
|
||||
const restoreMediaSourceApis = overrideMediaSourceApis({});
|
||||
|
||||
try {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
});
|
||||
|
||||
expect(pipeline.usesNativePlaybackFallback()).toBe(true);
|
||||
} finally {
|
||||
restoreMediaSourceApis();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not use native playback fallback on Apple mobile when MMS is available', () => {
|
||||
setAppleMobileNavigator();
|
||||
|
||||
class FakeManagedMediaSource {
|
||||
static isTypeSupported(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const restoreMediaSourceApis = overrideMediaSourceApis({
|
||||
managedMediaSourceCtor: FakeManagedMediaSource as unknown as MockMediaSourceCtor,
|
||||
});
|
||||
|
||||
try {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
});
|
||||
|
||||
expect(pipeline.usesNativePlaybackFallback()).toBe(false);
|
||||
} finally {
|
||||
restoreMediaSourceApis();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when neither ManagedMediaSource nor MediaSource exists on non-Apple browsers', async () => {
|
||||
setNonAppleNavigator();
|
||||
const restoreMediaSourceApis = overrideMediaSourceApis({});
|
||||
|
||||
try {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
});
|
||||
|
||||
await expect(
|
||||
pipeline.ensurePipeline('video/mp4'),
|
||||
).rejects.toThrow(
|
||||
'ManagedMediaSource/MediaSource APIs are not available in this browser.',
|
||||
);
|
||||
} finally {
|
||||
restoreMediaSourceApis();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps live playback enabled on Android Chrome when MediaSource is available', () => {
|
||||
setAndroidChromeNavigator();
|
||||
|
||||
class FakeMediaSource {
|
||||
static isTypeSupported(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const restoreMediaSourceApis = overrideMediaSourceApis({
|
||||
mediaSourceCtor: FakeMediaSource as unknown as MockMediaSourceCtor,
|
||||
});
|
||||
|
||||
try {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
});
|
||||
|
||||
expect(pipeline.usesNativePlaybackFallback()).toBe(false);
|
||||
} finally {
|
||||
restoreMediaSourceApis();
|
||||
}
|
||||
});
|
||||
|
||||
it('initializes playback with ManagedMediaSource when available', async () => {
|
||||
setAppleMobileNavigator();
|
||||
fakeVideo.disableRemotePlayback = false;
|
||||
|
||||
class FakeSourceBuffer {
|
||||
mode: AppendMode = 'segments';
|
||||
|
||||
updating = false;
|
||||
|
||||
addEventListener(): void {
|
||||
// noop for test
|
||||
}
|
||||
|
||||
removeEventListener(): void {
|
||||
// noop for test
|
||||
}
|
||||
|
||||
appendBuffer(): void {
|
||||
// noop for test
|
||||
}
|
||||
}
|
||||
|
||||
class FakeManagedMediaSource {
|
||||
static instances: FakeManagedMediaSource[] = [];
|
||||
|
||||
static isTypeSupported(_mime: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
readyState: MockMediaSourceReadyState = 'closed';
|
||||
|
||||
sourceBuffers: SourceBufferList = {} as SourceBufferList;
|
||||
|
||||
activeSourceBuffers: SourceBufferList = {} as SourceBufferList;
|
||||
|
||||
duration = 0;
|
||||
|
||||
onsourceopen: ((this: MediaSource, ev: Event) => any) | null = null;
|
||||
|
||||
onsourceended: ((this: MediaSource, ev: Event) => any) | null = null;
|
||||
|
||||
onsourceclose: ((this: MediaSource, ev: Event) => any) | null = null;
|
||||
|
||||
lastSourceBuffer: FakeSourceBuffer | null = null;
|
||||
|
||||
addSourceBuffer = vi.fn((_mime: string) => {
|
||||
const sourceBuffer = new FakeSourceBuffer();
|
||||
this.lastSourceBuffer = sourceBuffer;
|
||||
return sourceBuffer as unknown as SourceBuffer;
|
||||
});
|
||||
|
||||
removeSourceBuffer = vi.fn();
|
||||
|
||||
endOfStream = vi.fn(() => {
|
||||
this.readyState = 'ended';
|
||||
});
|
||||
|
||||
setLiveSeekableRange = vi.fn();
|
||||
|
||||
clearLiveSeekableRange = vi.fn();
|
||||
|
||||
private readonly listeners = new Map<string, Array<() => void>>();
|
||||
|
||||
constructor() {
|
||||
FakeManagedMediaSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: () => void): void {
|
||||
const next = this.listeners.get(type) || [];
|
||||
next.push(listener);
|
||||
this.listeners.set(type, next);
|
||||
|
||||
if (type === 'sourceopen') {
|
||||
setTimeout(() => {
|
||||
this.readyState = 'open';
|
||||
listener();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: () => void): void {
|
||||
const next = (this.listeners.get(type) || []).filter(
|
||||
(item) => item !== listener,
|
||||
);
|
||||
this.listeners.set(type, next);
|
||||
}
|
||||
|
||||
dispatchEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const restoreMediaSourceApis = overrideMediaSourceApis({
|
||||
managedMediaSourceCtor: FakeManagedMediaSource as unknown as MockMediaSourceCtor,
|
||||
});
|
||||
|
||||
try {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
});
|
||||
|
||||
await pipeline.ensurePipeline('video/mp4');
|
||||
|
||||
const managedInstance = FakeManagedMediaSource.instances[0];
|
||||
expect(managedInstance).toBeTruthy();
|
||||
expect(managedInstance?.addSourceBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(managedInstance?.addSourceBuffer).toHaveBeenCalledWith('video/mp4');
|
||||
expect(managedInstance?.lastSourceBuffer?.mode).toBe('sequence');
|
||||
expect(fakeVideo.disableRemotePlayback).toBe(true);
|
||||
} finally {
|
||||
restoreMediaSourceApis();
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for ManagedMediaSource startstreaming before appending queued chunks', async () => {
|
||||
setAppleMobileNavigator();
|
||||
const onAppendError = vi.fn();
|
||||
|
||||
class FakeSourceBuffer {
|
||||
mode: AppendMode = 'segments';
|
||||
|
||||
updating = false;
|
||||
|
||||
appendBuffer = vi.fn();
|
||||
|
||||
addEventListener(): void {
|
||||
// noop for test
|
||||
}
|
||||
|
||||
removeEventListener(): void {
|
||||
// noop for test
|
||||
}
|
||||
}
|
||||
|
||||
class FakeManagedMediaSource {
|
||||
static instances: FakeManagedMediaSource[] = [];
|
||||
|
||||
static isTypeSupported(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
streaming = false;
|
||||
|
||||
readyState: MockMediaSourceReadyState = 'closed';
|
||||
|
||||
sourceBuffers: SourceBufferList = {} as SourceBufferList;
|
||||
|
||||
activeSourceBuffers: SourceBufferList = {} as SourceBufferList;
|
||||
|
||||
duration = 0;
|
||||
|
||||
onsourceopen: ((this: MediaSource, ev: Event) => any) | null = null;
|
||||
|
||||
onsourceended: ((this: MediaSource, ev: Event) => any) | null = null;
|
||||
|
||||
onsourceclose: ((this: MediaSource, ev: Event) => any) | null = null;
|
||||
|
||||
lastSourceBuffer: FakeSourceBuffer | null = null;
|
||||
|
||||
addSourceBuffer = vi.fn(() => {
|
||||
const sourceBuffer = new FakeSourceBuffer();
|
||||
this.lastSourceBuffer = sourceBuffer;
|
||||
return sourceBuffer as unknown as SourceBuffer;
|
||||
});
|
||||
|
||||
removeSourceBuffer = vi.fn();
|
||||
|
||||
endOfStream = vi.fn(() => {
|
||||
this.readyState = 'ended';
|
||||
});
|
||||
|
||||
setLiveSeekableRange = vi.fn();
|
||||
|
||||
clearLiveSeekableRange = vi.fn();
|
||||
|
||||
private readonly listeners = new Map<string, Array<() => void>>();
|
||||
|
||||
constructor() {
|
||||
FakeManagedMediaSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: () => void): void {
|
||||
const next = this.listeners.get(type) || [];
|
||||
next.push(listener);
|
||||
this.listeners.set(type, next);
|
||||
|
||||
if (type === 'sourceopen') {
|
||||
setTimeout(() => {
|
||||
this.readyState = 'open';
|
||||
listener();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: () => void): void {
|
||||
const next = (this.listeners.get(type) || []).filter(
|
||||
(item) => item !== listener,
|
||||
);
|
||||
this.listeners.set(type, next);
|
||||
}
|
||||
|
||||
dispatch(type: string): void {
|
||||
for (const listener of this.listeners.get(type) || []) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
dispatchEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const restoreMediaSourceApis = overrideMediaSourceApis({
|
||||
managedMediaSourceCtor: FakeManagedMediaSource as unknown as MockMediaSourceCtor,
|
||||
});
|
||||
|
||||
try {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
onAppendError,
|
||||
});
|
||||
|
||||
await pipeline.ensurePipeline('video/mp4');
|
||||
const managedInstance = FakeManagedMediaSource.instances[0];
|
||||
expect(managedInstance).toBeTruthy();
|
||||
expect(managedInstance?.streaming).toBe(false);
|
||||
|
||||
pipeline.enqueueChunk(new Uint8Array([9, 8, 7]).buffer);
|
||||
expect(managedInstance?.lastSourceBuffer?.appendBuffer).not.toHaveBeenCalled();
|
||||
|
||||
managedInstance!.streaming = true;
|
||||
managedInstance!.dispatch('startstreaming');
|
||||
expect(managedInstance?.lastSourceBuffer?.appendBuffer).toHaveBeenCalledTimes(1);
|
||||
expect(onAppendError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
restoreMediaSourceApis();
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for the startup buffer before first playback', async () => {
|
||||
let nowMs = 0;
|
||||
const onPlaybackStarted = vi.fn();
|
||||
fakeVideo.currentTime = 0;
|
||||
fakeVideo.buffered = createBufferedRange(0, 0.2);
|
||||
fakeVideo.paused = true;
|
||||
fakeVideo.play.mockClear();
|
||||
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
getNow: () => nowMs,
|
||||
onPlaybackStarted,
|
||||
});
|
||||
|
||||
pipeline.markStreamStarting();
|
||||
pipeline.enqueueChunk(new Uint8Array([1, 2, 3]).buffer);
|
||||
|
||||
nowMs = 400;
|
||||
pipeline.maybeStartPlayback();
|
||||
expect(onPlaybackStarted).not.toHaveBeenCalled();
|
||||
expect(fakeVideo.play).not.toHaveBeenCalled();
|
||||
|
||||
nowMs = 500;
|
||||
pipeline.maybeStartPlayback();
|
||||
expect(onPlaybackStarted).toHaveBeenCalledTimes(1);
|
||||
expect(fakeVideo.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resumes immediately after a later buffering stall once data is available', async () => {
|
||||
let nowMs = 0;
|
||||
const onPlaybackStarted = vi.fn();
|
||||
fakeVideo.currentTime = 0;
|
||||
fakeVideo.buffered = createBufferedRange(0, 0.2);
|
||||
fakeVideo.paused = true;
|
||||
fakeVideo.play.mockClear();
|
||||
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
getNow: () => nowMs,
|
||||
onPlaybackStarted,
|
||||
});
|
||||
|
||||
pipeline.markStreamStarting();
|
||||
pipeline.enqueueChunk(new Uint8Array([1, 2, 3]).buffer);
|
||||
|
||||
nowMs = 500;
|
||||
pipeline.maybeStartPlayback();
|
||||
expect(onPlaybackStarted).toHaveBeenCalledTimes(1);
|
||||
expect(fakeVideo.play).toHaveBeenCalledTimes(1);
|
||||
|
||||
fakeVideo.paused = true;
|
||||
fakeVideo.currentTime = 0.2;
|
||||
fakeVideo.buffered = createBufferedRange(0.2, 0.35);
|
||||
fakeVideo.dispatch('waiting');
|
||||
|
||||
nowMs = 510;
|
||||
pipeline.maybeStartPlayback();
|
||||
expect(onPlaybackStarted).toHaveBeenCalledTimes(1);
|
||||
expect(fakeVideo.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns archived chunks and clears them when taken', () => {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
});
|
||||
|
||||
const originalChunk = new Uint8Array([7, 8, 9]).buffer;
|
||||
pipeline.markStreamStarting();
|
||||
pipeline.enqueueChunk(originalChunk);
|
||||
|
||||
const archivedChunks = pipeline.takeArchivedStreamChunks();
|
||||
|
||||
expect(archivedChunks).toHaveLength(1);
|
||||
expect(archivedChunks[0]).not.toBe(originalChunk);
|
||||
expect(Array.from(new Uint8Array(archivedChunks[0]))).toEqual([7, 8, 9]);
|
||||
expect(pipeline.hasArchivedChunks()).toBe(false);
|
||||
});
|
||||
|
||||
it('tracks archived segments by lifecycle and supports completed-only snapshots', () => {
|
||||
const pipeline = createAvPipeline({
|
||||
getVideoEl: () => fakeVideo,
|
||||
});
|
||||
|
||||
pipeline.markStreamStarting();
|
||||
pipeline.noteSegmentInit({
|
||||
segmentIdx: 1,
|
||||
streamId: 'seg-1',
|
||||
mime: 'video/mp4',
|
||||
});
|
||||
pipeline.enqueueChunk(new Uint8Array([1, 2]).buffer);
|
||||
pipeline.noteSegmentComplete({
|
||||
segmentIdx: 1,
|
||||
streamId: 'seg-1',
|
||||
});
|
||||
|
||||
pipeline.noteSegmentInit({
|
||||
segmentIdx: 2,
|
||||
streamId: 'seg-2',
|
||||
mime: 'video/mp4',
|
||||
});
|
||||
pipeline.enqueueChunk(new Uint8Array([3, 4]).buffer);
|
||||
|
||||
const allSegments = pipeline.buildArchivedSegmentSnapshots({
|
||||
includeInProgress: true,
|
||||
});
|
||||
expect(allSegments).toHaveLength(2);
|
||||
expect(allSegments[0]?.completed).toBe(true);
|
||||
expect(allSegments[1]?.completed).toBe(false);
|
||||
expect(pipeline.hasArchivedCompletedSegments()).toBe(true);
|
||||
|
||||
const completedOnly = pipeline.takeArchivedSegmentSnapshots({
|
||||
includeInProgress: false,
|
||||
});
|
||||
expect(completedOnly).toHaveLength(1);
|
||||
expect(completedOnly[0]?.streamId).toBe('seg-1');
|
||||
|
||||
const remaining = pipeline.buildArchivedSegmentSnapshots({
|
||||
includeInProgress: true,
|
||||
});
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]?.streamId).toBe('seg-2');
|
||||
expect(remaining[0]?.completed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,995 @@
|
||||
export const DEFAULT_AV_MIME = 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"';
|
||||
|
||||
const AV_MIME_FALLBACKS = [
|
||||
'video/mp4; codecs="avc1.4d401e,mp4a.40.2"',
|
||||
'video/mp4; codecs="avc1.42e01e,mp4a.40.2"',
|
||||
'video/mp4; codecs="avc1.640028,mp4a.40.2"',
|
||||
'video/mp4',
|
||||
];
|
||||
|
||||
const AV_INITIAL_MIN_START_DELAY_MS = 0;
|
||||
const AV_INITIAL_PREBUFFER_SECONDS = 1.0;
|
||||
const AV_INITIAL_MAX_START_WAIT_MS = 500;
|
||||
|
||||
type MediaSourceLike = MediaSource;
|
||||
|
||||
type MediaSourceLikeConstructor = {
|
||||
new (): MediaSourceLike;
|
||||
isTypeSupported: (mime: string) => boolean;
|
||||
};
|
||||
|
||||
interface ManagedMediaSourceLike extends MediaSource {
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
interface MediaSourceWindow extends Window {
|
||||
ManagedMediaSource?: MediaSourceLikeConstructor;
|
||||
MediaSource?: MediaSourceLikeConstructor;
|
||||
}
|
||||
|
||||
function isAppleMobileBrowser(): boolean {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAgent = String(navigator.userAgent || '');
|
||||
const platform = String(navigator.platform || '');
|
||||
const maxTouchPoints = Number(navigator.maxTouchPoints || 0);
|
||||
const isAppleMobileUserAgent = /iPhone|iPad|iPod/i.test(userAgent);
|
||||
const isTouchMac = /Mac/i.test(platform) && maxTouchPoints > 1;
|
||||
|
||||
return isAppleMobileUserAgent || isTouchMac;
|
||||
}
|
||||
|
||||
function isAndroidChromeBrowser(): boolean {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAgent = String(navigator.userAgent || '');
|
||||
return /Android/i.test(userAgent)
|
||||
&& /(Chrome|CriOS)/i.test(userAgent)
|
||||
&& !/(EdgA|OPR|SamsungBrowser)/i.test(userAgent);
|
||||
}
|
||||
|
||||
function resolveMediaSourceConstructor(): {
|
||||
ctor: MediaSourceLikeConstructor | null;
|
||||
usesManagedMediaSource: boolean;
|
||||
} {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
ctor: null,
|
||||
usesManagedMediaSource: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mediaSourceWindow = window as MediaSourceWindow;
|
||||
if (typeof mediaSourceWindow.ManagedMediaSource === 'function') {
|
||||
return {
|
||||
ctor: mediaSourceWindow.ManagedMediaSource,
|
||||
usesManagedMediaSource: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof mediaSourceWindow.MediaSource === 'function') {
|
||||
return {
|
||||
ctor: mediaSourceWindow.MediaSource,
|
||||
usesManagedMediaSource: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ctor: null,
|
||||
usesManagedMediaSource: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AvPipeline {
|
||||
ensurePipeline(mime: string, forceRecreate?: boolean): Promise<void>;
|
||||
enqueueChunk(chunk: ArrayBuffer | ArrayBufferView): void;
|
||||
noteSegmentInit(meta?: {
|
||||
segmentIdx?: number | null;
|
||||
streamId?: string;
|
||||
mime?: string;
|
||||
}): void;
|
||||
noteSegmentComplete(meta?: {
|
||||
segmentIdx?: number | null;
|
||||
streamId?: string;
|
||||
}): void;
|
||||
maybeStartPlayback(): void;
|
||||
tryEndStream(): void;
|
||||
markStreamStarting(): void;
|
||||
setStreamCompleted(value: boolean): void;
|
||||
hasArchivedChunks(): boolean;
|
||||
hasArchivedCompletedSegments(): boolean;
|
||||
buildArchivedStreamChunks(): ArrayBuffer[];
|
||||
buildArchivedSegmentSnapshots(options?: {
|
||||
includeInProgress?: boolean;
|
||||
}): ArchivedAvSegment[];
|
||||
buildArchivedStreamBlob(): Blob | null;
|
||||
takeArchivedStreamChunks(): ArrayBuffer[];
|
||||
takeArchivedSegmentSnapshots(options?: {
|
||||
includeInProgress?: boolean;
|
||||
}): ArchivedAvSegment[];
|
||||
takeArchivedStreamBlob(): Blob | null;
|
||||
usesNativePlaybackFallback(): boolean;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export interface ArchivedAvSegment {
|
||||
key: string;
|
||||
segmentIdx: number | null;
|
||||
streamId: string;
|
||||
mime: string;
|
||||
completed: boolean;
|
||||
chunks: ArrayBuffer[];
|
||||
}
|
||||
|
||||
interface CreateAvPipelineParams {
|
||||
getVideoEl: () => HTMLVideoElement | null;
|
||||
getNow?: () => number;
|
||||
onAppendError?: (message: string, error?: any) => void;
|
||||
onPlaybackStarted?: () => void;
|
||||
}
|
||||
|
||||
export function createAvPipeline({
|
||||
getVideoEl,
|
||||
getNow = () => performance.now(),
|
||||
onAppendError = () => {},
|
||||
onPlaybackStarted = () => {},
|
||||
}: CreateAvPipelineParams): AvPipeline {
|
||||
const {
|
||||
ctor: mediaSourceCtor,
|
||||
usesManagedMediaSource,
|
||||
} = resolveMediaSourceConstructor();
|
||||
// Only force native fallback when the browser lacks a usable media
|
||||
// source implementation. Android Chrome should stay on the faster
|
||||
// live playback path when MediaSource is available.
|
||||
const useNativePlaybackFallback = (
|
||||
(isAppleMobileBrowser() && !mediaSourceCtor)
|
||||
|| (isAndroidChromeBrowser() && !mediaSourceCtor)
|
||||
);
|
||||
let sourceBuffer: SourceBuffer | null = null;
|
||||
let mediaSource: MediaSource | null = null;
|
||||
let mediaObjectUrl: string | null = null;
|
||||
let mediaSourceOpen = false;
|
||||
let sourceBufferMime = '';
|
||||
let mediaChunkQueue: ArrayBuffer[] = [];
|
||||
let sourceBufferUpdateEndHandler: (() => void) | null = null;
|
||||
let sourceBufferErrorHandler: ((event: Event) => void) | null = null;
|
||||
let mediaSourceOpenHandler: (() => void) | null = null;
|
||||
let mediaSourceStartStreamingHandler: (() => void) | null = null;
|
||||
let mediaSourceEndStreamingHandler: (() => void) | null = null;
|
||||
let pendingMediaEndOfStream = false;
|
||||
let managedStreamingActive = !usesManagedMediaSource;
|
||||
let firstChunkAtMs: number | null = null;
|
||||
let streamCompleted = false;
|
||||
let initialPlaybackStarted = false;
|
||||
let playbackResumePending = false;
|
||||
let archivedChunks: ArrayBuffer[] = [];
|
||||
let archivedSegments: ArchivedAvSegment[] = [];
|
||||
let activeArchivedSegmentKey: string | null = null;
|
||||
let archivedSegmentCounter = 0;
|
||||
let boundVideoEl: HTMLVideoElement | null = null;
|
||||
let videoWaitingHandler: (() => void) | null = null;
|
||||
let videoStalledHandler: (() => void) | null = null;
|
||||
let videoPlayingHandler: (() => void) | null = null;
|
||||
let videoPauseHandler: (() => void) | null = null;
|
||||
let videoPlayHandler: (() => void) | null = null;
|
||||
let userPaused = false;
|
||||
let stallRecoveryTimerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function uniqValues(values: any[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || result.includes(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
result.push(trimmed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isMimeTypeSupported(mime: string): boolean {
|
||||
if (!mediaSourceCtor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return mediaSourceCtor.isTypeSupported(mime);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSupportedAvMime(mime: string): string | null {
|
||||
const candidates = uniqValues([
|
||||
mime,
|
||||
DEFAULT_AV_MIME,
|
||||
...AV_MIME_FALLBACKS,
|
||||
]);
|
||||
for (const candidate of candidates) {
|
||||
if (isMimeTypeSupported(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listAvMimeCandidates(mime: string): string[] {
|
||||
return uniqValues([
|
||||
mime,
|
||||
DEFAULT_AV_MIME,
|
||||
...AV_MIME_FALLBACKS,
|
||||
]);
|
||||
}
|
||||
|
||||
function cleanupVideoBindings(): void {
|
||||
if (!boundVideoEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoWaitingHandler) {
|
||||
boundVideoEl.removeEventListener('waiting', videoWaitingHandler);
|
||||
}
|
||||
if (videoStalledHandler) {
|
||||
boundVideoEl.removeEventListener('stalled', videoStalledHandler);
|
||||
}
|
||||
if (videoPlayingHandler) {
|
||||
boundVideoEl.removeEventListener('playing', videoPlayingHandler);
|
||||
}
|
||||
if (videoPauseHandler) {
|
||||
boundVideoEl.removeEventListener('pause', videoPauseHandler);
|
||||
}
|
||||
if (videoPlayHandler) {
|
||||
boundVideoEl.removeEventListener('play', videoPlayHandler);
|
||||
}
|
||||
|
||||
boundVideoEl = null;
|
||||
videoWaitingHandler = null;
|
||||
videoStalledHandler = null;
|
||||
videoPlayingHandler = null;
|
||||
videoPauseHandler = null;
|
||||
videoPlayHandler = null;
|
||||
clearStallRecovery();
|
||||
}
|
||||
|
||||
function clearStallRecovery(): void {
|
||||
if (stallRecoveryTimerId !== null) {
|
||||
clearInterval(stallRecoveryTimerId);
|
||||
stallRecoveryTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startStallRecovery(): void {
|
||||
if (stallRecoveryTimerId !== null) {
|
||||
return;
|
||||
}
|
||||
stallRecoveryTimerId = setInterval(() => {
|
||||
// Stop polling once playback is running normally.
|
||||
if (initialPlaybackStarted && !playbackResumePending) {
|
||||
clearStallRecovery();
|
||||
return;
|
||||
}
|
||||
// On iOS Safari, ManagedMediaSource's startstreaming event can be
|
||||
// missed or delayed. Poll the streaming property directly to detect
|
||||
// when appending is allowed again.
|
||||
if (usesManagedMediaSource && !managedStreamingActive && mediaSource) {
|
||||
const managed = mediaSource as ManagedMediaSourceLike;
|
||||
if (managed.streaming !== false) {
|
||||
managedStreamingActive = true;
|
||||
flushQueue();
|
||||
}
|
||||
}
|
||||
maybeStartPlayback();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function ensureVideoBindings(videoEl: HTMLVideoElement): void {
|
||||
if (!videoEl || boundVideoEl === videoEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupVideoBindings();
|
||||
boundVideoEl = videoEl;
|
||||
videoWaitingHandler = () => {
|
||||
if (initialPlaybackStarted) {
|
||||
playbackResumePending = true;
|
||||
startStallRecovery();
|
||||
}
|
||||
};
|
||||
videoStalledHandler = () => {
|
||||
if (initialPlaybackStarted) {
|
||||
playbackResumePending = true;
|
||||
startStallRecovery();
|
||||
}
|
||||
};
|
||||
videoPlayingHandler = () => {
|
||||
playbackResumePending = false;
|
||||
clearStallRecovery();
|
||||
};
|
||||
videoPauseHandler = () => {
|
||||
if (initialPlaybackStarted) {
|
||||
userPaused = true;
|
||||
}
|
||||
};
|
||||
videoPlayHandler = () => {
|
||||
userPaused = false;
|
||||
};
|
||||
|
||||
videoEl.addEventListener('waiting', videoWaitingHandler);
|
||||
videoEl.addEventListener('stalled', videoStalledHandler);
|
||||
videoEl.addEventListener('playing', videoPlayingHandler);
|
||||
videoEl.addEventListener('pause', videoPauseHandler);
|
||||
videoEl.addEventListener('play', videoPlayHandler);
|
||||
}
|
||||
|
||||
function cloneChunk(chunk: ArrayBuffer | ArrayBufferView): ArrayBuffer {
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
return chunk.slice(0);
|
||||
}
|
||||
if (ArrayBuffer.isView(chunk)) {
|
||||
return new Uint8Array(
|
||||
chunk.buffer,
|
||||
chunk.byteOffset,
|
||||
chunk.byteLength,
|
||||
).slice().buffer;
|
||||
}
|
||||
return new Uint8Array(chunk).slice().buffer;
|
||||
}
|
||||
|
||||
function resetArchivedSegments(): void {
|
||||
archivedSegments = [];
|
||||
activeArchivedSegmentKey = null;
|
||||
archivedSegmentCounter = 0;
|
||||
}
|
||||
|
||||
function cloneArchivedSegment(segment: ArchivedAvSegment): ArchivedAvSegment {
|
||||
return {
|
||||
...segment,
|
||||
chunks: segment.chunks.map((chunk) => cloneChunk(chunk)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildArchivedSegmentKey(
|
||||
segmentIdx: number | null,
|
||||
streamId: string,
|
||||
): string {
|
||||
return `${segmentIdx !== null ? segmentIdx : 'na'}:${streamId || 'na'}`;
|
||||
}
|
||||
|
||||
function normalizeSegmentMeta({
|
||||
segmentIdx = null,
|
||||
streamId = '',
|
||||
mime = '',
|
||||
}: {
|
||||
segmentIdx?: number | null;
|
||||
streamId?: string;
|
||||
mime?: string;
|
||||
}): {
|
||||
segmentIdx: number | null;
|
||||
streamId: string;
|
||||
mime: string;
|
||||
} {
|
||||
const normalizedSegmentIdx =
|
||||
Number.isInteger(segmentIdx) ? Number(segmentIdx) : null;
|
||||
const normalizedStreamId = typeof streamId === 'string'
|
||||
&& streamId.trim()
|
||||
? streamId.trim()
|
||||
: '';
|
||||
const normalizedMime = typeof mime === 'string' && mime.trim()
|
||||
? mime.trim()
|
||||
: sourceBufferMime || DEFAULT_AV_MIME;
|
||||
return {
|
||||
segmentIdx: normalizedSegmentIdx,
|
||||
streamId: normalizedStreamId,
|
||||
mime: normalizedMime,
|
||||
};
|
||||
}
|
||||
|
||||
function getOrCreateActiveSegment(): ArchivedAvSegment {
|
||||
if (activeArchivedSegmentKey) {
|
||||
const existing = archivedSegments.find(
|
||||
(segment) => segment.key === activeArchivedSegmentKey,
|
||||
);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
archivedSegmentCounter += 1;
|
||||
const streamId = `implicit-${archivedSegmentCounter}`;
|
||||
const key = buildArchivedSegmentKey(null, streamId);
|
||||
const nextSegment: ArchivedAvSegment = {
|
||||
key,
|
||||
segmentIdx: null,
|
||||
streamId,
|
||||
mime: sourceBufferMime || DEFAULT_AV_MIME,
|
||||
completed: false,
|
||||
chunks: [],
|
||||
};
|
||||
archivedSegments = [...archivedSegments, nextSegment];
|
||||
activeArchivedSegmentKey = key;
|
||||
return nextSegment;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
// NOTE: mediaChunkQueue is intentionally NOT cleared here.
|
||||
// ensurePipeline() calls cleanup() to tear down the old
|
||||
// MediaSource, but chunks may have already been enqueued for
|
||||
// the new stream during the async gap before ensurePipeline
|
||||
// runs. Clearing them here drops the fMP4 init segment,
|
||||
// causing black video / append errors on mobile.
|
||||
// The queue is cleared in reset() and markStreamStarting().
|
||||
mediaSourceOpen = false;
|
||||
sourceBufferMime = '';
|
||||
pendingMediaEndOfStream = false;
|
||||
managedStreamingActive = !usesManagedMediaSource;
|
||||
firstChunkAtMs = null;
|
||||
streamCompleted = false;
|
||||
initialPlaybackStarted = false;
|
||||
playbackResumePending = false;
|
||||
archivedChunks = [];
|
||||
resetArchivedSegments();
|
||||
|
||||
if (sourceBuffer && sourceBufferUpdateEndHandler) {
|
||||
sourceBuffer.removeEventListener('updateend', sourceBufferUpdateEndHandler);
|
||||
}
|
||||
if (sourceBuffer && sourceBufferErrorHandler) {
|
||||
sourceBuffer.removeEventListener('error', sourceBufferErrorHandler);
|
||||
}
|
||||
sourceBuffer = null;
|
||||
sourceBufferUpdateEndHandler = null;
|
||||
sourceBufferErrorHandler = null;
|
||||
|
||||
if (mediaSource && mediaSourceOpenHandler) {
|
||||
mediaSource.removeEventListener('sourceopen', mediaSourceOpenHandler);
|
||||
}
|
||||
if (mediaSource && mediaSourceStartStreamingHandler) {
|
||||
mediaSource.removeEventListener(
|
||||
'startstreaming',
|
||||
mediaSourceStartStreamingHandler,
|
||||
);
|
||||
}
|
||||
if (mediaSource && mediaSourceEndStreamingHandler) {
|
||||
mediaSource.removeEventListener(
|
||||
'endstreaming',
|
||||
mediaSourceEndStreamingHandler,
|
||||
);
|
||||
}
|
||||
if (mediaSource && mediaSource.readyState === 'open') {
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch (error) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
mediaSource = null;
|
||||
mediaSourceOpenHandler = null;
|
||||
mediaSourceStartStreamingHandler = null;
|
||||
mediaSourceEndStreamingHandler = null;
|
||||
|
||||
const videoEl = getVideoEl();
|
||||
cleanupVideoBindings();
|
||||
if (videoEl) {
|
||||
try {
|
||||
videoEl.pause();
|
||||
} catch (error) {
|
||||
// noop
|
||||
}
|
||||
if ((videoEl as any).srcObject) {
|
||||
(videoEl as any).srcObject = null;
|
||||
} else {
|
||||
videoEl.removeAttribute('src');
|
||||
}
|
||||
videoEl.load();
|
||||
}
|
||||
|
||||
if (mediaObjectUrl) {
|
||||
URL.revokeObjectURL(mediaObjectUrl);
|
||||
mediaObjectUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function flushQueue(): void {
|
||||
if (!sourceBuffer || !mediaSourceOpen || !mediaSource) return;
|
||||
if (mediaSource.readyState !== 'open') return;
|
||||
if (usesManagedMediaSource && !managedStreamingActive) return;
|
||||
if (sourceBuffer.updating) return;
|
||||
if (mediaChunkQueue.length === 0) return;
|
||||
|
||||
const nextChunk = mediaChunkQueue.shift()!;
|
||||
try {
|
||||
sourceBuffer.appendBuffer(nextChunk);
|
||||
} catch (error) {
|
||||
const errorName = (error as { name?: unknown })?.name;
|
||||
if (
|
||||
usesManagedMediaSource
|
||||
&& errorName === 'InvalidStateError'
|
||||
) {
|
||||
const managedMediaSource = mediaSource as ManagedMediaSourceLike;
|
||||
if (managedMediaSource.streaming === false) {
|
||||
managedStreamingActive = false;
|
||||
mediaChunkQueue = [nextChunk, ...mediaChunkQueue];
|
||||
return;
|
||||
}
|
||||
}
|
||||
mediaChunkQueue = [];
|
||||
onAppendError('Unable to append media chunk.', error);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueChunk(chunk: ArrayBuffer | ArrayBufferView): void {
|
||||
if (firstChunkAtMs === null) {
|
||||
firstChunkAtMs = getNow();
|
||||
}
|
||||
const clonedChunk = cloneChunk(chunk);
|
||||
archivedChunks = [...archivedChunks, clonedChunk];
|
||||
const archivedSegment = getOrCreateActiveSegment();
|
||||
archivedSegment.chunks.push(clonedChunk);
|
||||
mediaChunkQueue.push(clonedChunk);
|
||||
flushQueue();
|
||||
// If chunks couldn't be flushed (e.g. ManagedMediaSource streaming
|
||||
// is not yet active), start the recovery poll so we don't deadlock
|
||||
// waiting for a startstreaming event that may be delayed.
|
||||
if (mediaChunkQueue.length > 0 && usesManagedMediaSource) {
|
||||
startStallRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
function noteSegmentInit({
|
||||
segmentIdx = null,
|
||||
streamId = '',
|
||||
mime = '',
|
||||
}: {
|
||||
segmentIdx?: number | null;
|
||||
streamId?: string;
|
||||
mime?: string;
|
||||
} = {}): void {
|
||||
const normalized = normalizeSegmentMeta({
|
||||
segmentIdx,
|
||||
streamId,
|
||||
mime,
|
||||
});
|
||||
|
||||
let normalizedStreamId = normalized.streamId;
|
||||
if (!normalizedStreamId) {
|
||||
archivedSegmentCounter += 1;
|
||||
normalizedStreamId = `stream-${archivedSegmentCounter}`;
|
||||
}
|
||||
|
||||
const key = buildArchivedSegmentKey(
|
||||
normalized.segmentIdx,
|
||||
normalizedStreamId,
|
||||
);
|
||||
let segment = archivedSegments.find((entry) => entry.key === key);
|
||||
if (!segment) {
|
||||
segment = {
|
||||
key,
|
||||
segmentIdx: normalized.segmentIdx,
|
||||
streamId: normalizedStreamId,
|
||||
mime: normalized.mime,
|
||||
completed: false,
|
||||
chunks: [],
|
||||
};
|
||||
archivedSegments = [...archivedSegments, segment];
|
||||
} else {
|
||||
segment.segmentIdx = normalized.segmentIdx;
|
||||
segment.streamId = normalizedStreamId;
|
||||
segment.mime = normalized.mime;
|
||||
segment.completed = false;
|
||||
}
|
||||
|
||||
activeArchivedSegmentKey = key;
|
||||
}
|
||||
|
||||
function noteSegmentComplete({
|
||||
segmentIdx = null,
|
||||
streamId = '',
|
||||
}: {
|
||||
segmentIdx?: number | null;
|
||||
streamId?: string;
|
||||
} = {}): void {
|
||||
const normalizedSegmentIdx =
|
||||
Number.isInteger(segmentIdx) ? Number(segmentIdx) : null;
|
||||
const normalizedStreamId = typeof streamId === 'string'
|
||||
&& streamId.trim()
|
||||
? streamId.trim()
|
||||
: '';
|
||||
|
||||
let targetSegment: ArchivedAvSegment | undefined;
|
||||
|
||||
if (normalizedStreamId) {
|
||||
targetSegment = archivedSegments.find(
|
||||
(segment) =>
|
||||
segment.streamId === normalizedStreamId
|
||||
&& (
|
||||
normalizedSegmentIdx === null
|
||||
|| segment.segmentIdx === normalizedSegmentIdx
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetSegment && normalizedSegmentIdx !== null) {
|
||||
const matchingSegments = archivedSegments.filter(
|
||||
(segment) =>
|
||||
segment.segmentIdx === normalizedSegmentIdx && !segment.completed,
|
||||
);
|
||||
targetSegment = matchingSegments[matchingSegments.length - 1];
|
||||
}
|
||||
|
||||
if (!targetSegment && activeArchivedSegmentKey) {
|
||||
targetSegment = archivedSegments.find(
|
||||
(segment) => segment.key === activeArchivedSegmentKey,
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetSegment) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetSegment.completed = true;
|
||||
if (targetSegment.key === activeArchivedSegmentKey) {
|
||||
activeArchivedSegmentKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBufferedAheadSeconds(): number {
|
||||
const videoEl = getVideoEl();
|
||||
if (!videoEl) return 0;
|
||||
|
||||
let buffered = 0;
|
||||
try {
|
||||
const ranges = videoEl.buffered;
|
||||
if (!ranges || ranges.length === 0) return 0;
|
||||
const t = Math.max(videoEl.currentTime || 0, 0);
|
||||
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const start = ranges.start(i);
|
||||
const end = ranges.end(i);
|
||||
if (t >= start && t <= end) {
|
||||
buffered = Math.max(0, end - t);
|
||||
break;
|
||||
}
|
||||
if (t < start) {
|
||||
buffered = Math.max(0, end - start);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
buffered = 0;
|
||||
}
|
||||
|
||||
return buffered;
|
||||
}
|
||||
|
||||
function maybeStartPlayback(): void {
|
||||
const videoEl = getVideoEl();
|
||||
if (!videoEl) return;
|
||||
ensureVideoBindings(videoEl);
|
||||
|
||||
const bufferedAhead = getBufferedAheadSeconds();
|
||||
if (bufferedAhead <= 0) return;
|
||||
|
||||
if (initialPlaybackStarted) {
|
||||
if (!playbackResumePending || userPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
playbackResumePending = false;
|
||||
const resumePromise = videoEl.play();
|
||||
if (resumePromise?.catch) {
|
||||
resumePromise.catch((err) => {
|
||||
console.warn('av resume play() rejected:', err);
|
||||
playbackResumePending = true;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstChunkAtMs === null) return;
|
||||
|
||||
const waitedMs = getNow() - firstChunkAtMs;
|
||||
const delayElapsed = waitedMs >= AV_INITIAL_MIN_START_DELAY_MS;
|
||||
const enoughBuffer = bufferedAhead >= AV_INITIAL_PREBUFFER_SECONDS;
|
||||
const maxWaitReached = AV_INITIAL_MAX_START_WAIT_MS > 0
|
||||
&& waitedMs >= AV_INITIAL_MAX_START_WAIT_MS;
|
||||
|
||||
if (!streamCompleted && (!delayElapsed || (!enoughBuffer && !maxWaitReached))) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialPlaybackStarted = true;
|
||||
playbackResumePending = false;
|
||||
onPlaybackStarted();
|
||||
|
||||
const playPromise = videoEl.play();
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch((err) => {
|
||||
console.warn('av initial play() rejected:', err);
|
||||
playbackResumePending = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function tryEndStream(): void {
|
||||
if (!mediaSource || !mediaSourceOpen) return;
|
||||
|
||||
if (sourceBuffer?.updating) {
|
||||
pendingMediaEndOfStream = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaChunkQueue.length > 0) {
|
||||
pendingMediaEndOfStream = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
mediaSource.endOfStream();
|
||||
} catch (error) {
|
||||
// noop
|
||||
}
|
||||
mediaSourceOpen = false;
|
||||
pendingMediaEndOfStream = false;
|
||||
}
|
||||
|
||||
async function ensurePipeline(
|
||||
mime: string,
|
||||
forceRecreate = false,
|
||||
): Promise<void> {
|
||||
const requestedMime = mime || DEFAULT_AV_MIME;
|
||||
const selectedMime = resolveSupportedAvMime(requestedMime);
|
||||
const candidateMimes = listAvMimeCandidates(requestedMime);
|
||||
|
||||
if (!forceRecreate
|
||||
&& sourceBuffer
|
||||
&& mediaSourceOpen
|
||||
&& sourceBufferMime === selectedMime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useNativePlaybackFallback) {
|
||||
const videoEl = getVideoEl();
|
||||
if (!videoEl) {
|
||||
throw new Error('Video element is not ready.');
|
||||
}
|
||||
|
||||
sourceBufferMime = requestedMime;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mediaSourceCtor) {
|
||||
throw new Error(
|
||||
'ManagedMediaSource/MediaSource APIs are not available in this browser.',
|
||||
);
|
||||
}
|
||||
if (!selectedMime) {
|
||||
throw new Error(
|
||||
`No supported AV MIME type found for requested "${requestedMime}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const videoEl = getVideoEl();
|
||||
if (!videoEl) {
|
||||
throw new Error('Video element is not ready.');
|
||||
}
|
||||
|
||||
cleanup();
|
||||
mediaSource = new mediaSourceCtor();
|
||||
if (usesManagedMediaSource) {
|
||||
videoEl.disableRemotePlayback = true;
|
||||
(videoEl as any).srcObject = mediaSource;
|
||||
} else {
|
||||
mediaObjectUrl = URL.createObjectURL(mediaSource);
|
||||
videoEl.src = mediaObjectUrl;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mediaSourceOpenHandler = () => {
|
||||
try {
|
||||
let sourceBufferInitialized = false;
|
||||
const sourceBufferErrors: string[] = [];
|
||||
for (const candidateMime of candidateMimes) {
|
||||
if (!isMimeTypeSupported(candidateMime)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
sourceBuffer = mediaSource!.addSourceBuffer(candidateMime);
|
||||
sourceBufferMime = candidateMime;
|
||||
sourceBufferInitialized = true;
|
||||
break;
|
||||
} catch (error: any) {
|
||||
sourceBufferErrors.push(
|
||||
`${candidateMime}: ${error?.message || String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceBufferInitialized) {
|
||||
throw new Error(
|
||||
'Unable to initialize SourceBuffer. Candidates: '
|
||||
+ `${candidateMimes.join(', ')}`
|
||||
+ (
|
||||
sourceBufferErrors.length
|
||||
? ` Errors: ${sourceBufferErrors.join('; ')}`
|
||||
: ''
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ensureVideoBindings(videoEl);
|
||||
sourceBuffer!.mode = 'sequence';
|
||||
mediaSourceOpen = true;
|
||||
if (usesManagedMediaSource) {
|
||||
const managedMediaSource = mediaSource! as ManagedMediaSourceLike;
|
||||
managedStreamingActive = managedMediaSource.streaming !== false;
|
||||
mediaSourceStartStreamingHandler = () => {
|
||||
managedStreamingActive = true;
|
||||
flushQueue();
|
||||
};
|
||||
mediaSourceEndStreamingHandler = () => {
|
||||
managedStreamingActive = false;
|
||||
};
|
||||
mediaSource!.addEventListener(
|
||||
'startstreaming',
|
||||
mediaSourceStartStreamingHandler,
|
||||
);
|
||||
mediaSource!.addEventListener(
|
||||
'endstreaming',
|
||||
mediaSourceEndStreamingHandler,
|
||||
);
|
||||
}
|
||||
|
||||
sourceBufferUpdateEndHandler = () => {
|
||||
flushQueue();
|
||||
maybeStartPlayback();
|
||||
if (pendingMediaEndOfStream) {
|
||||
tryEndStream();
|
||||
}
|
||||
};
|
||||
|
||||
sourceBufferErrorHandler = (event: Event) => {
|
||||
onAppendError('SourceBuffer reported a media error.', event);
|
||||
};
|
||||
|
||||
sourceBuffer!.addEventListener('updateend', sourceBufferUpdateEndHandler);
|
||||
sourceBuffer!.addEventListener('error', sourceBufferErrorHandler);
|
||||
|
||||
flushQueue();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
mediaSource!.addEventListener('sourceopen', mediaSourceOpenHandler, {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function markStreamStarting(): void {
|
||||
mediaChunkQueue = [];
|
||||
streamCompleted = false;
|
||||
firstChunkAtMs = null;
|
||||
initialPlaybackStarted = false;
|
||||
playbackResumePending = false;
|
||||
userPaused = false;
|
||||
pendingMediaEndOfStream = false;
|
||||
archivedChunks = [];
|
||||
resetArchivedSegments();
|
||||
clearStallRecovery();
|
||||
}
|
||||
|
||||
function setStreamCompleted(value: boolean): void {
|
||||
streamCompleted = Boolean(value);
|
||||
}
|
||||
|
||||
function hasArchivedChunks(): boolean {
|
||||
return archivedChunks.length > 0;
|
||||
}
|
||||
|
||||
function hasArchivedCompletedSegments(): boolean {
|
||||
return archivedSegments.some(
|
||||
(segment) => segment.completed && segment.chunks.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function buildArchivedStreamChunks(): ArrayBuffer[] {
|
||||
if (!hasArchivedChunks()) {
|
||||
return [];
|
||||
}
|
||||
return archivedChunks.map((chunk) => cloneChunk(chunk));
|
||||
}
|
||||
|
||||
function buildArchivedSegmentSnapshots({
|
||||
includeInProgress = true,
|
||||
}: {
|
||||
includeInProgress?: boolean;
|
||||
} = {}): ArchivedAvSegment[] {
|
||||
return archivedSegments
|
||||
.filter(
|
||||
(segment) =>
|
||||
segment.chunks.length > 0
|
||||
&& (includeInProgress || segment.completed),
|
||||
)
|
||||
.map((segment) => cloneArchivedSegment(segment));
|
||||
}
|
||||
|
||||
function buildArchivedStreamBlob(): Blob | null {
|
||||
const chunks = buildArchivedStreamChunks();
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return new Blob(chunks, {
|
||||
type: sourceBufferMime || DEFAULT_AV_MIME,
|
||||
});
|
||||
}
|
||||
|
||||
function takeArchivedStreamChunks(): ArrayBuffer[] {
|
||||
const chunks = buildArchivedStreamChunks();
|
||||
archivedChunks = [];
|
||||
resetArchivedSegments();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function takeArchivedSegmentSnapshots({
|
||||
includeInProgress = true,
|
||||
}: {
|
||||
includeInProgress?: boolean;
|
||||
} = {}): ArchivedAvSegment[] {
|
||||
const snapshots = buildArchivedSegmentSnapshots({ includeInProgress });
|
||||
if (includeInProgress) {
|
||||
resetArchivedSegments();
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
archivedSegments = archivedSegments.filter((segment) => !segment.completed);
|
||||
if (
|
||||
activeArchivedSegmentKey
|
||||
&& !archivedSegments.some((segment) => segment.key === activeArchivedSegmentKey)
|
||||
) {
|
||||
activeArchivedSegmentKey = null;
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
function takeArchivedStreamBlob(): Blob | null {
|
||||
const blob = buildArchivedStreamBlob();
|
||||
archivedChunks = [];
|
||||
return blob;
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
mediaChunkQueue = [];
|
||||
cleanup();
|
||||
}
|
||||
|
||||
return {
|
||||
ensurePipeline,
|
||||
enqueueChunk,
|
||||
noteSegmentInit,
|
||||
noteSegmentComplete,
|
||||
maybeStartPlayback,
|
||||
tryEndStream,
|
||||
markStreamStarting,
|
||||
setStreamCompleted,
|
||||
hasArchivedChunks,
|
||||
hasArchivedCompletedSegments,
|
||||
buildArchivedStreamChunks,
|
||||
buildArchivedSegmentSnapshots,
|
||||
buildArchivedStreamBlob,
|
||||
takeArchivedStreamChunks,
|
||||
takeArchivedSegmentSnapshots,
|
||||
takeArchivedStreamBlob,
|
||||
usesNativePlaybackFallback() {
|
||||
return useNativePlaybackFallback;
|
||||
},
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
Fmp4RemuxError,
|
||||
remuxArchivedFmp4Segments,
|
||||
} from './fmp4Remux';
|
||||
|
||||
const createFileMock = vi.fn();
|
||||
|
||||
vi.mock('mp4box', () => ({
|
||||
createFile: (...args: any[]) => createFileMock(...args),
|
||||
}));
|
||||
|
||||
function buildParser({
|
||||
tracks,
|
||||
samplesByTrack,
|
||||
trackType = 'avc1',
|
||||
throwOnAppend = false,
|
||||
}: {
|
||||
tracks: Array<Record<string, any>>;
|
||||
samplesByTrack: Record<number, any[]>;
|
||||
trackType?: string;
|
||||
throwOnAppend?: boolean;
|
||||
}) {
|
||||
const parser: any = {
|
||||
onReady: null,
|
||||
onSamples: null,
|
||||
onError: null,
|
||||
setExtractionOptions: vi.fn(),
|
||||
start: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
getTrackById: vi.fn((trackId: number) => ({
|
||||
mdia: {
|
||||
minf: {
|
||||
stbl: {
|
||||
stsd: {
|
||||
entries: [
|
||||
{
|
||||
type: trackType,
|
||||
boxes: [{ type: 'avcC' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
appendBuffer: vi.fn(() => {
|
||||
if (throwOnAppend) {
|
||||
throw new Error('append failure');
|
||||
}
|
||||
parser.onReady?.({
|
||||
timescale: 1000,
|
||||
brands: ['isom'],
|
||||
tracks,
|
||||
});
|
||||
for (const track of tracks) {
|
||||
parser.onSamples?.(
|
||||
track.id,
|
||||
null,
|
||||
samplesByTrack[track.id] || [],
|
||||
);
|
||||
}
|
||||
}),
|
||||
};
|
||||
return parser;
|
||||
}
|
||||
|
||||
function buildWriter() {
|
||||
const recordedSamples: Array<Record<string, any>> = [];
|
||||
const writer: any = {
|
||||
init: vi.fn(),
|
||||
addTrack: vi.fn((options: Record<string, any>) => Number(options.id)),
|
||||
addSample: vi.fn(
|
||||
(trackId: number, data: Uint8Array, options: Record<string, any>) => {
|
||||
recordedSamples.push({
|
||||
trackId,
|
||||
data,
|
||||
options,
|
||||
});
|
||||
},
|
||||
),
|
||||
getBuffer: vi.fn(() => {
|
||||
const buffer = new ArrayBuffer(64);
|
||||
return {
|
||||
buffer,
|
||||
byteLength: buffer.byteLength,
|
||||
};
|
||||
}),
|
||||
recordedSamples,
|
||||
};
|
||||
return writer;
|
||||
}
|
||||
|
||||
function buildSample({
|
||||
dts,
|
||||
duration,
|
||||
value,
|
||||
}: {
|
||||
dts: number;
|
||||
duration: number;
|
||||
value: number;
|
||||
}) {
|
||||
return {
|
||||
dts,
|
||||
cts: dts,
|
||||
duration,
|
||||
description_index: 0,
|
||||
is_sync: true,
|
||||
is_leading: 0,
|
||||
depends_on: 0,
|
||||
is_depended_on: 0,
|
||||
has_redundancy: 0,
|
||||
degradation_priority: 0,
|
||||
data: new Uint8Array([value]),
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_TRACK = {
|
||||
id: 1,
|
||||
codec: 'avc1.640028',
|
||||
timescale: 1000,
|
||||
language: 'und',
|
||||
video: {
|
||||
width: 640,
|
||||
height: 360,
|
||||
},
|
||||
};
|
||||
|
||||
describe('remuxArchivedFmp4Segments', () => {
|
||||
beforeEach(() => {
|
||||
createFileMock.mockReset();
|
||||
});
|
||||
|
||||
it('remuxes multiple segments into one playable MP4 blob', async () => {
|
||||
const parserA = buildParser({
|
||||
tracks: [BASE_TRACK],
|
||||
samplesByTrack: {
|
||||
1: [
|
||||
buildSample({ dts: 0, duration: 5, value: 1 }),
|
||||
buildSample({ dts: 5, duration: 5, value: 2 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
const parserB = buildParser({
|
||||
tracks: [BASE_TRACK],
|
||||
samplesByTrack: {
|
||||
1: [
|
||||
buildSample({ dts: 0, duration: 5, value: 3 }),
|
||||
buildSample({ dts: 5, duration: 5, value: 4 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
const writer = buildWriter();
|
||||
|
||||
createFileMock
|
||||
.mockImplementationOnce(() => parserA)
|
||||
.mockImplementationOnce(() => parserB)
|
||||
.mockImplementationOnce(() => writer);
|
||||
|
||||
const blob = await remuxArchivedFmp4Segments([
|
||||
{
|
||||
key: 'seg-1',
|
||||
segmentIdx: 1,
|
||||
streamId: 's1',
|
||||
mime: 'video/mp4',
|
||||
completed: true,
|
||||
chunks: [new Uint8Array([1, 2, 3]).buffer],
|
||||
},
|
||||
{
|
||||
key: 'seg-2',
|
||||
segmentIdx: 2,
|
||||
streamId: 's2',
|
||||
mime: 'video/mp4',
|
||||
completed: true,
|
||||
chunks: [new Uint8Array([4, 5, 6]).buffer],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(blob.type).toBe('video/mp4');
|
||||
expect(writer.addTrack).toHaveBeenCalledTimes(1);
|
||||
expect(writer.addSample).toHaveBeenCalledTimes(4);
|
||||
const sampleCalls = writer.recordedSamples;
|
||||
expect(sampleCalls[0]?.options?.dts).toBe(0);
|
||||
expect(sampleCalls[2]?.options?.dts).toBe(10);
|
||||
});
|
||||
|
||||
it('fails with includeInProgress=true and succeeds with completed-only fallback', async () => {
|
||||
const validParser = buildParser({
|
||||
tracks: [BASE_TRACK],
|
||||
samplesByTrack: {
|
||||
1: [buildSample({ dts: 0, duration: 5, value: 7 })],
|
||||
},
|
||||
});
|
||||
const brokenParser = buildParser({
|
||||
tracks: [BASE_TRACK],
|
||||
samplesByTrack: {},
|
||||
throwOnAppend: true,
|
||||
});
|
||||
const writerForSuccess = buildWriter();
|
||||
|
||||
createFileMock
|
||||
.mockImplementationOnce(() => validParser)
|
||||
.mockImplementationOnce(() => brokenParser);
|
||||
|
||||
await expect(
|
||||
remuxArchivedFmp4Segments([
|
||||
{
|
||||
key: 'completed',
|
||||
segmentIdx: 1,
|
||||
streamId: 's1',
|
||||
mime: 'video/mp4',
|
||||
completed: true,
|
||||
chunks: [new Uint8Array([1]).buffer],
|
||||
},
|
||||
{
|
||||
key: 'in-progress',
|
||||
segmentIdx: 2,
|
||||
streamId: 's2',
|
||||
mime: 'video/mp4',
|
||||
completed: false,
|
||||
chunks: [new Uint8Array([2]).buffer],
|
||||
},
|
||||
], {
|
||||
includeInProgress: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Fmp4RemuxError);
|
||||
|
||||
createFileMock
|
||||
.mockReset()
|
||||
.mockImplementationOnce(() => validParser)
|
||||
.mockImplementationOnce(() => writerForSuccess);
|
||||
|
||||
const blob = await remuxArchivedFmp4Segments([
|
||||
{
|
||||
key: 'completed',
|
||||
segmentIdx: 1,
|
||||
streamId: 's1',
|
||||
mime: 'video/mp4',
|
||||
completed: true,
|
||||
chunks: [new Uint8Array([1]).buffer],
|
||||
},
|
||||
{
|
||||
key: 'in-progress',
|
||||
segmentIdx: 2,
|
||||
streamId: 's2',
|
||||
mime: 'video/mp4',
|
||||
completed: false,
|
||||
chunks: [new Uint8Array([2]).buffer],
|
||||
},
|
||||
], {
|
||||
includeInProgress: false,
|
||||
});
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(writerForSuccess.addSample).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws no_segments when no eligible segments exist', async () => {
|
||||
await expect(
|
||||
remuxArchivedFmp4Segments([], {
|
||||
includeInProgress: false,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'no_segments',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import { createFile } from 'mp4box';
|
||||
|
||||
import type { ArchivedAvSegment } from './avPipeline';
|
||||
|
||||
const DEFAULT_REMUX_MIME = 'video/mp4';
|
||||
|
||||
export type Fmp4RemuxErrorCode =
|
||||
| 'no_segments'
|
||||
| 'segment_parse_failed'
|
||||
| 'remux_failed';
|
||||
|
||||
export class Fmp4RemuxError extends Error {
|
||||
code: Fmp4RemuxErrorCode;
|
||||
cause?: unknown;
|
||||
|
||||
constructor(
|
||||
code: Fmp4RemuxErrorCode,
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'Fmp4RemuxError';
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RemuxArchivedFmp4Options {
|
||||
includeInProgress?: boolean;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
interface ParsedTrack {
|
||||
sourceTrackId: number;
|
||||
type: string;
|
||||
timescale: number;
|
||||
width: number;
|
||||
height: number;
|
||||
channelCount: number;
|
||||
sampleRate: number;
|
||||
sampleSize: number;
|
||||
language: string;
|
||||
handler: string;
|
||||
descriptionBoxes: any[];
|
||||
samples: any[];
|
||||
}
|
||||
|
||||
interface ParsedSegment {
|
||||
movieTimescale: number;
|
||||
brands: string[];
|
||||
tracks: Map<number, ParsedTrack>;
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function concatArrayBuffers(chunks: ArrayBuffer[]): ArrayBuffer {
|
||||
const totalSize = chunks.reduce(
|
||||
(sum, chunk) => sum + chunk.byteLength,
|
||||
0,
|
||||
);
|
||||
const output = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output.buffer;
|
||||
}
|
||||
|
||||
function normalizeTrackType(codec: string, fallback = 'avc1'): string {
|
||||
const source = typeof codec === 'string' ? codec.trim() : '';
|
||||
const firstToken = source.split('.')[0] || '';
|
||||
if (firstToken.length === 4) {
|
||||
return firstToken;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function cloneSampleData(data: unknown): Uint8Array<ArrayBuffer> | null {
|
||||
if (data instanceof Uint8Array) {
|
||||
return new Uint8Array(data).slice();
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data.slice(0));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeSegmentTiming(samples: any[]): {
|
||||
segmentStartDts: number;
|
||||
segmentDuration: number;
|
||||
} {
|
||||
if (!samples.length) {
|
||||
return {
|
||||
segmentStartDts: 0,
|
||||
segmentDuration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let minDts = Number.POSITIVE_INFINITY;
|
||||
let maxEnd = 0;
|
||||
for (const sample of samples) {
|
||||
const dts = toFiniteNumber(sample?.dts, 0);
|
||||
const duration = Math.max(1, toFiniteNumber(sample?.duration, 1));
|
||||
minDts = Math.min(minDts, dts);
|
||||
maxEnd = Math.max(maxEnd, dts + duration);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(minDts)) {
|
||||
minDts = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
segmentStartDts: minDts,
|
||||
segmentDuration: Math.max(0, maxEnd - minDts),
|
||||
};
|
||||
}
|
||||
|
||||
async function parseArchivedSegment(
|
||||
segment: ArchivedAvSegment,
|
||||
): Promise<ParsedSegment> {
|
||||
const segmentBuffer = concatArrayBuffers(segment.chunks);
|
||||
if (segmentBuffer.byteLength === 0) {
|
||||
throw new Fmp4RemuxError(
|
||||
'segment_parse_failed',
|
||||
`Segment "${segment.key}" has no bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<ParsedSegment>((resolve, reject) => {
|
||||
const parser: any = createFile(true);
|
||||
const samplesByTrackId = new Map<number, any[]>();
|
||||
let readyInfo: any = null;
|
||||
let settled = false;
|
||||
|
||||
const fail = (error: unknown, defaultMessage: string): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (error instanceof Fmp4RemuxError) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new Fmp4RemuxError(
|
||||
'segment_parse_failed',
|
||||
defaultMessage,
|
||||
error,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readyInfo || !Array.isArray(readyInfo?.tracks)) {
|
||||
fail(
|
||||
null,
|
||||
`Unable to parse MP4 metadata for segment "${segment.key}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = new Map<number, ParsedTrack>();
|
||||
for (const trackInfo of readyInfo.tracks as any[]) {
|
||||
const sourceTrackId = Number(trackInfo?.id);
|
||||
if (!Number.isInteger(sourceTrackId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceTrack = parser.getTrackById(sourceTrackId);
|
||||
const sampleDescription = sourceTrack?.mdia?.minf?.stbl?.stsd?.entries?.[0];
|
||||
const descriptionBoxes = Array.isArray(sampleDescription?.boxes)
|
||||
? sampleDescription.boxes
|
||||
: [];
|
||||
|
||||
tracks.set(sourceTrackId, {
|
||||
sourceTrackId,
|
||||
type: normalizeTrackType(
|
||||
String(sampleDescription?.type || trackInfo?.codec || ''),
|
||||
trackInfo?.audio ? 'mp4a' : 'avc1',
|
||||
),
|
||||
timescale: Math.max(1, toFiniteNumber(trackInfo?.timescale, 1)),
|
||||
width: Math.max(0, toFiniteNumber(trackInfo?.video?.width, 0)),
|
||||
height: Math.max(0, toFiniteNumber(trackInfo?.video?.height, 0)),
|
||||
channelCount: Math.max(
|
||||
0,
|
||||
toFiniteNumber(trackInfo?.audio?.channel_count, 0),
|
||||
),
|
||||
sampleRate: Math.max(
|
||||
0,
|
||||
toFiniteNumber(trackInfo?.audio?.sample_rate, 0),
|
||||
),
|
||||
sampleSize: Math.max(
|
||||
0,
|
||||
toFiniteNumber(trackInfo?.audio?.sample_size, 0),
|
||||
),
|
||||
language:
|
||||
typeof trackInfo?.language === 'string'
|
||||
&& trackInfo.language.trim()
|
||||
? trackInfo.language.trim()
|
||||
: 'und',
|
||||
handler: trackInfo?.audio ? 'soun' : 'vide',
|
||||
descriptionBoxes,
|
||||
samples: samplesByTrackId.get(sourceTrackId) || [],
|
||||
});
|
||||
}
|
||||
|
||||
settled = true;
|
||||
resolve({
|
||||
movieTimescale: Math.max(1, toFiniteNumber(readyInfo?.timescale, 600)),
|
||||
brands: Array.isArray(readyInfo?.brands)
|
||||
? readyInfo.brands.filter(
|
||||
(brand: unknown) =>
|
||||
typeof brand === 'string' && brand.trim(),
|
||||
)
|
||||
: [],
|
||||
tracks,
|
||||
});
|
||||
};
|
||||
|
||||
parser.onError = (module: string, message: string) => {
|
||||
fail(
|
||||
null,
|
||||
`MP4 parse error in segment "${segment.key}" (${module}): ${message}`,
|
||||
);
|
||||
};
|
||||
|
||||
parser.onReady = (info: any) => {
|
||||
readyInfo = info;
|
||||
const tracks = Array.isArray(info?.tracks) ? info.tracks : [];
|
||||
for (const track of tracks) {
|
||||
if (!Number.isInteger(track?.id)) {
|
||||
continue;
|
||||
}
|
||||
samplesByTrackId.set(track.id, []);
|
||||
parser.setExtractionOptions(track.id, null, {
|
||||
nbSamples: Number.MAX_SAFE_INTEGER,
|
||||
rapAlignement: false,
|
||||
});
|
||||
}
|
||||
parser.start();
|
||||
};
|
||||
|
||||
parser.onSamples = (
|
||||
trackId: number,
|
||||
_user: unknown,
|
||||
samples: any[],
|
||||
) => {
|
||||
const nextSamples = samplesByTrackId.get(trackId) || [];
|
||||
for (const sample of samples || []) {
|
||||
const copiedData = cloneSampleData(sample?.data);
|
||||
if (!copiedData || copiedData.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
nextSamples.push({
|
||||
...sample,
|
||||
data: copiedData,
|
||||
});
|
||||
}
|
||||
samplesByTrackId.set(trackId, nextSamples);
|
||||
};
|
||||
|
||||
try {
|
||||
const mp4Buffer = segmentBuffer.slice(0) as ArrayBuffer & {
|
||||
fileStart?: number;
|
||||
};
|
||||
mp4Buffer.fileStart = 0;
|
||||
parser.appendBuffer(mp4Buffer);
|
||||
parser.flush();
|
||||
Promise.resolve().then(finish);
|
||||
} catch (error) {
|
||||
fail(
|
||||
error,
|
||||
`Failed to append MP4 segment "${segment.key}" to parser.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function remuxArchivedFmp4Segments(
|
||||
segments: ArchivedAvSegment[],
|
||||
{
|
||||
includeInProgress = true,
|
||||
mimeType = DEFAULT_REMUX_MIME,
|
||||
}: RemuxArchivedFmp4Options = {},
|
||||
): Promise<Blob> {
|
||||
const selectedSegments = (Array.isArray(segments) ? segments : [])
|
||||
.filter(
|
||||
(segment) =>
|
||||
Array.isArray(segment?.chunks)
|
||||
&& segment.chunks.length > 0
|
||||
&& (includeInProgress || Boolean(segment?.completed)),
|
||||
);
|
||||
|
||||
if (selectedSegments.length === 0) {
|
||||
throw new Fmp4RemuxError(
|
||||
'no_segments',
|
||||
includeInProgress
|
||||
? 'No archived stream data is available for remuxing.'
|
||||
: 'No completed segments are available for remuxing.',
|
||||
);
|
||||
}
|
||||
|
||||
const parsedSegments = await Promise.all(
|
||||
selectedSegments.map((segment) => parseArchivedSegment(segment)),
|
||||
);
|
||||
|
||||
const firstWithTracks = parsedSegments.find(
|
||||
(segment) => segment.tracks.size > 0,
|
||||
);
|
||||
if (!firstWithTracks) {
|
||||
throw new Fmp4RemuxError(
|
||||
'remux_failed',
|
||||
'No tracks were found while remuxing archived segments.',
|
||||
);
|
||||
}
|
||||
|
||||
const outputFile: any = createFile(false);
|
||||
outputFile.init({
|
||||
brands: firstWithTracks.brands.length > 0
|
||||
? firstWithTracks.brands
|
||||
: ['isom'],
|
||||
timescale: firstWithTracks.movieTimescale,
|
||||
});
|
||||
|
||||
const outputTrackBySourceTrack = new Map<number, number>();
|
||||
const trackOffsetBySourceTrack = new Map<number, number>();
|
||||
|
||||
for (const [sourceTrackId, parsedTrack] of firstWithTracks.tracks.entries()) {
|
||||
const trackOptions: Record<string, unknown> = {
|
||||
id: sourceTrackId,
|
||||
type: parsedTrack.type,
|
||||
timescale: parsedTrack.timescale,
|
||||
hdlr: parsedTrack.handler,
|
||||
language: parsedTrack.language,
|
||||
};
|
||||
if (parsedTrack.width > 0) {
|
||||
trackOptions.width = parsedTrack.width;
|
||||
}
|
||||
if (parsedTrack.height > 0) {
|
||||
trackOptions.height = parsedTrack.height;
|
||||
}
|
||||
if (parsedTrack.channelCount > 0) {
|
||||
trackOptions.channel_count = parsedTrack.channelCount;
|
||||
}
|
||||
if (parsedTrack.sampleSize > 0) {
|
||||
trackOptions.samplesize = parsedTrack.sampleSize;
|
||||
}
|
||||
if (parsedTrack.sampleRate > 0) {
|
||||
trackOptions.samplerate = parsedTrack.sampleRate * 65536;
|
||||
}
|
||||
if (parsedTrack.descriptionBoxes.length > 0) {
|
||||
trackOptions.description_boxes = parsedTrack.descriptionBoxes;
|
||||
}
|
||||
|
||||
const outputTrackId = outputFile.addTrack(trackOptions);
|
||||
if (!Number.isInteger(outputTrackId)) {
|
||||
throw new Fmp4RemuxError(
|
||||
'remux_failed',
|
||||
`Unable to create output track for source track ${sourceTrackId}.`,
|
||||
);
|
||||
}
|
||||
outputTrackBySourceTrack.set(sourceTrackId, Number(outputTrackId));
|
||||
trackOffsetBySourceTrack.set(sourceTrackId, 0);
|
||||
}
|
||||
|
||||
let writtenSampleCount = 0;
|
||||
|
||||
for (const parsedSegment of parsedSegments) {
|
||||
for (const [
|
||||
sourceTrackId,
|
||||
outputTrackId,
|
||||
] of outputTrackBySourceTrack.entries()) {
|
||||
const parsedTrack = parsedSegment.tracks.get(sourceTrackId);
|
||||
if (!parsedTrack || parsedTrack.samples.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { segmentStartDts, segmentDuration } = computeSegmentTiming(
|
||||
parsedTrack.samples,
|
||||
);
|
||||
const trackOffset = trackOffsetBySourceTrack.get(sourceTrackId) || 0;
|
||||
|
||||
for (const sample of parsedTrack.samples) {
|
||||
const sampleData = cloneSampleData(sample?.data);
|
||||
if (!sampleData || sampleData.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sampleDts = toFiniteNumber(sample?.dts, 0);
|
||||
const sampleCts = toFiniteNumber(sample?.cts, sampleDts);
|
||||
const sampleDuration = Math.max(
|
||||
1,
|
||||
Math.round(toFiniteNumber(sample?.duration, 1)),
|
||||
);
|
||||
|
||||
outputFile.addSample(outputTrackId, sampleData, {
|
||||
sample_description_index: Math.max(
|
||||
1,
|
||||
Math.round(toFiniteNumber(sample?.description_index, 0)) + 1,
|
||||
),
|
||||
duration: sampleDuration,
|
||||
dts: Math.round(sampleDts - segmentStartDts + trackOffset),
|
||||
cts: Math.round(sampleCts - segmentStartDts + trackOffset),
|
||||
is_sync: Boolean(sample?.is_sync),
|
||||
is_leading: Math.round(toFiniteNumber(sample?.is_leading, 0)),
|
||||
depends_on: Math.round(toFiniteNumber(sample?.depends_on, 0)),
|
||||
is_depended_on: Math.round(
|
||||
toFiniteNumber(sample?.is_depended_on, 0),
|
||||
),
|
||||
has_redundancy: Math.round(
|
||||
toFiniteNumber(sample?.has_redundancy, 0),
|
||||
),
|
||||
degradation_priority: Math.round(
|
||||
toFiniteNumber(sample?.degradation_priority, 0),
|
||||
),
|
||||
});
|
||||
writtenSampleCount += 1;
|
||||
}
|
||||
|
||||
trackOffsetBySourceTrack.set(
|
||||
sourceTrackId,
|
||||
trackOffset + segmentDuration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (writtenSampleCount === 0) {
|
||||
throw new Fmp4RemuxError(
|
||||
'remux_failed',
|
||||
'No media samples were available for remux output.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const outputStream = outputFile.getBuffer();
|
||||
const outputBytes = outputStream.buffer.slice(0, outputStream.byteLength);
|
||||
return new Blob([outputBytes], { type: mimeType || DEFAULT_REMUX_MIME });
|
||||
} catch (error) {
|
||||
throw new Fmp4RemuxError(
|
||||
'remux_failed',
|
||||
'Failed to build remuxed MP4 buffer.',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPromptWindowSnapshot,
|
||||
buildRewritePromptWindowSnapshot,
|
||||
buildRewritePromptWindowSnapshotFromPrompts,
|
||||
normalizePromptWindowSnapshot,
|
||||
} from './promptWindowSnapshot';
|
||||
|
||||
describe('normalizePromptWindowSnapshot', () => {
|
||||
it('trims prompts and removes empty entries while preserving order', () => {
|
||||
expect(normalizePromptWindowSnapshot([
|
||||
' first ',
|
||||
'',
|
||||
' ',
|
||||
'second',
|
||||
' third ',
|
||||
])).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array for non-array input', () => {
|
||||
expect(normalizePromptWindowSnapshot(null)).toEqual([]);
|
||||
expect(normalizePromptWindowSnapshot({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPromptWindowSnapshot', () => {
|
||||
it('builds the payload from currentPromptWindowPrompts only', () => {
|
||||
expect(buildPromptWindowSnapshot({
|
||||
currentPromptWindowPrompts: [' current 1 ', 'current 2'],
|
||||
seedPrompts: ['seed 1', 'seed 2'],
|
||||
outboundSessionPrompts: ['outbound 1', 'outbound 2'],
|
||||
})).toEqual([
|
||||
'current 1',
|
||||
'current 2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRewritePromptWindowSnapshot', () => {
|
||||
it('pads short prompt windows to 6 entries', () => {
|
||||
const result = buildRewritePromptWindowSnapshot({
|
||||
currentPromptWindowPrompts: ['prompt 1', 'prompt 2'],
|
||||
});
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0]).toBe('prompt 1');
|
||||
expect(result[1]).toBe('prompt 2');
|
||||
for (let i = 2; i < 6; i++) {
|
||||
expect(result[i]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a 6-element window unchanged', () => {
|
||||
const prompts = ['a', 'b', 'c', 'd', 'e', 'f'];
|
||||
const result = buildRewritePromptWindowSnapshot({
|
||||
currentPromptWindowPrompts: prompts,
|
||||
});
|
||||
expect(result).toEqual(prompts);
|
||||
});
|
||||
|
||||
it('returns empty array when snapshot is empty', () => {
|
||||
expect(buildRewritePromptWindowSnapshot({
|
||||
currentPromptWindowPrompts: [],
|
||||
})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRewritePromptWindowSnapshotFromPrompts', () => {
|
||||
it('pads a direct prompt list the same way as store-backed snapshots', () => {
|
||||
const result = buildRewritePromptWindowSnapshotFromPrompts([
|
||||
' selected 1 ',
|
||||
'selected 2',
|
||||
]);
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0]).toBe('selected 1');
|
||||
expect(result[1]).toBe('selected 2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const REWRITE_SEGMENT_COUNT = 6;
|
||||
const CONTINUATION_FILL =
|
||||
'The scene continues naturally, maintaining the current mood and pacing.';
|
||||
|
||||
export function normalizePromptWindowSnapshot(
|
||||
prompts: any,
|
||||
): string[] {
|
||||
if (!Array.isArray(prompts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return prompts
|
||||
.map((prompt: any) => (typeof prompt === 'string' ? prompt.trim() : ''))
|
||||
.filter((prompt: string) => prompt.length > 0);
|
||||
}
|
||||
|
||||
export function buildPromptWindowSnapshot(
|
||||
promptWindowState: any,
|
||||
): string[] {
|
||||
return normalizePromptWindowSnapshot(promptWindowState?.currentPromptWindowPrompts);
|
||||
}
|
||||
|
||||
export function buildRewritePromptWindowSnapshotFromPrompts(
|
||||
prompts: any,
|
||||
): string[] {
|
||||
const snapshot = normalizePromptWindowSnapshot(prompts);
|
||||
while (snapshot.length > 0 && snapshot.length < REWRITE_SEGMENT_COUNT) {
|
||||
snapshot.push(CONTINUATION_FILL);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad snapshot to the segment count the server-side rewrite system
|
||||
* prompt expects, so the LLM response length matches validation.
|
||||
*/
|
||||
export function buildRewritePromptWindowSnapshot(
|
||||
promptWindowState: any,
|
||||
): string[] {
|
||||
return buildRewritePromptWindowSnapshotFromPrompts(
|
||||
buildPromptWindowSnapshot(promptWindowState),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user