@xcelsior/demo-pipeline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Full pipeline: capture web (+ mobile) -> voice-over -> sync assets -> render.
3
+ # Env: RUN_DIR (default runs/<timestamp>), MOBILE=0 to skip, MUSIC=1 to add music bed.
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")/.." # tool root
6
+
7
+ CFG=demo.config.json
8
+ get() { node -e "console.log((require('./$CFG').$1)??'')"; }
9
+
10
+ RUN="${RUN_DIR:-runs/$(date +%y%m%d-%H%M%S)}"
11
+ mkdir -p "$RUN"/{raw,events,vo,edited,final}
12
+ ABS_RUN="$PWD/$RUN"
13
+ echo "RUN=$RUN"
14
+
15
+ export LNG_BASE_URL="$(get baseUrl)"
16
+ export LNG_EMAIL="${LNG_EMAIL:-$(get auth.defaultEmail)}"
17
+ export LNG_PWD="${LNG_PWD:-}"
18
+ export UDID="$(get sim.udid)"
19
+
20
+ echo "== 1/5 capture web =="
21
+ RUN_DIR="$ABS_RUN" .venv/bin/python pipeline/capture_web.py
22
+
23
+ if [ "${MOBILE:-1}" = "1" ] && command -v maestro >/dev/null; then
24
+ echo "== 2/5 capture mobile =="
25
+ RUN_DIR="$ABS_RUN" bash pipeline/capture_mobile.sh || echo " mobile skipped/failed"
26
+ fi
27
+
28
+ echo "== 3/5 voice-over (Kokoro) =="
29
+ .venv/bin/python pipeline/gen_vo.py models "$RUN/vo"
30
+
31
+ echo "== 4/5 sync assets into remotion =="
32
+ mkdir -p remotion/public/run/vo remotion/public/brand
33
+ cp "$RUN/raw/web.webm" remotion/public/run/ 2>/dev/null || true
34
+ cp "$RUN/raw/mobile.mov" remotion/public/run/ 2>/dev/null || true
35
+ cp "$RUN"/vo/*.wav remotion/public/run/vo/ 2>/dev/null || true
36
+ cp "$RUN/events/web.events.json" remotion/src/edit/web.events.json
37
+ cp "$RUN/events/mobile.events.json" remotion/src/edit/mobile.events.json 2>/dev/null || true
38
+ cp "$RUN/vo/manifest.json" remotion/src/edit/vo-manifest.json
39
+ LOGO="$(get brandLogo)"; [ -n "$LOGO" ] && [ -f "$LOGO" ] && cp "$LOGO" remotion/public/brand/logo-white.png || true
40
+ [ "${MUSIC:-0}" = "1" ] && .venv/bin/python pipeline/gen_music.py remotion/public/run/music.wav || true
41
+
42
+ echo "== 5/5 render =="
43
+ ( cd remotion && ./node_modules/.bin/remotion render Edit "../$RUN/final/demo-final.mp4" --image-format=jpeg )
44
+ ( cd remotion && ./node_modules/.bin/remotion render EditMute "../$RUN/edited/demo-edited.mp4" --image-format=jpeg )
45
+ echo "✅ DONE -> $RUN/final/demo-final.mp4"
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "lng-demo-remotion",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "render": "remotion render Demo out/demo.mp4 --image-format=jpeg",
7
+ "studio": "remotion studio"
8
+ },
9
+ "dependencies": {
10
+ "@remotion/cli": "^4.0.0",
11
+ "@remotion/google-fonts": "^4.0.0",
12
+ "react": "18.3.1",
13
+ "react-dom": "18.3.1",
14
+ "remotion": "^4.0.0"
15
+ }
16
+ }
@@ -0,0 +1,5 @@
1
+ import { Config } from '@remotion/cli/config';
2
+
3
+ Config.setVideoImageFormat('jpeg');
4
+ Config.setOverwriteOutput(true);
5
+ Config.setConcurrency(4);
@@ -0,0 +1,18 @@
1
+ import type React from 'react';
2
+ import { AbsoluteFill, Series } from 'remotion';
3
+ import { SCENES, BRAND } from './scenes';
4
+ import { Scene } from './Scene';
5
+
6
+ export const DemoVideo: React.FC = () => {
7
+ return (
8
+ <AbsoluteFill style={{ background: BRAND.darker }}>
9
+ <Series>
10
+ {SCENES.map((s) => (
11
+ <Series.Sequence key={s.id} durationInFrames={s.durF}>
12
+ <Scene s={s} />
13
+ </Series.Sequence>
14
+ ))}
15
+ </Series>
16
+ </AbsoluteFill>
17
+ );
18
+ };
@@ -0,0 +1,47 @@
1
+ import type React from 'react';
2
+ import { Composition } from 'remotion';
3
+ import { DemoVideo } from './DemoVideo';
4
+ import { FPS, TOTAL_FRAMES } from './scenes';
5
+ import { EditVideo, TOTAL_EDIT } from './edit/EditVideo';
6
+
7
+ export const RemotionRoot: React.FC = () => {
8
+ return (
9
+ <>
10
+ {/* OpenScreen-style edit of the real recordings (with VO) */}
11
+ <Composition
12
+ id="Edit"
13
+ component={EditVideo}
14
+ durationInFrames={TOTAL_EDIT}
15
+ fps={FPS}
16
+ width={1920}
17
+ height={1080}
18
+ defaultProps={{
19
+ withVo: true,
20
+ framed: false,
21
+ music: false,
22
+ cursorTight: false,
23
+ mobileSync: false,
24
+ }}
25
+ />
26
+ {/* Edited recording, no voice-over (visual edit only) */}
27
+ <Composition
28
+ id="EditMute"
29
+ component={EditVideo}
30
+ durationInFrames={TOTAL_EDIT}
31
+ fps={FPS}
32
+ width={1920}
33
+ height={1080}
34
+ defaultProps={{ withVo: false }}
35
+ />
36
+ {/* legacy screenshot slideshow */}
37
+ <Composition
38
+ id="Demo"
39
+ component={DemoVideo}
40
+ durationInFrames={TOTAL_FRAMES}
41
+ fps={FPS}
42
+ width={1920}
43
+ height={1080}
44
+ />
45
+ </>
46
+ );
47
+ };
@@ -0,0 +1,131 @@
1
+ import type React from 'react';
2
+ import {
3
+ AbsoluteFill,
4
+ Img,
5
+ Audio,
6
+ Sequence,
7
+ staticFile,
8
+ interpolate,
9
+ useCurrentFrame,
10
+ } from 'remotion';
11
+ import { BRAND, type Focus, type SceneDef } from './scenes';
12
+ import { Caption, LabelChip, TitleCard, Vignette, useFade } from './ui';
13
+
14
+ const FocalImage: React.FC<{ src: string; focus: Focus; durF: number }> = ({
15
+ src,
16
+ focus,
17
+ durF,
18
+ }) => {
19
+ const frame = useCurrentFrame();
20
+ const p = durF > 0 ? frame / durF : 0;
21
+ const ox = focus.to ? interpolate(p, [0, 1], [focus.x, focus.to.x]) : focus.x;
22
+ const oy = focus.to ? interpolate(p, [0, 1], [focus.y, focus.to.y]) : focus.y;
23
+ const zoom = interpolate(p, [0, 1], [focus.zoom * 0.97, focus.zoom * 1.05]);
24
+ return (
25
+ <Img
26
+ src={staticFile('shots/' + src)}
27
+ style={{
28
+ width: '100%',
29
+ height: '100%',
30
+ objectFit: 'cover',
31
+ transform: `scale(${zoom})`,
32
+ transformOrigin: `${ox * 100}% ${oy * 100}%`,
33
+ }}
34
+ />
35
+ );
36
+ };
37
+
38
+ const Vo: React.FC<{ vo: string }> = ({ vo }) => (
39
+ <Sequence from={10}>
40
+ <Audio src={staticFile('vo/' + vo + '.wav')} volume={1} />
41
+ </Sequence>
42
+ );
43
+
44
+ const WebScene: React.FC<{ s: SceneDef }> = ({ s }) => {
45
+ const opacity = useFade(s.durF);
46
+ return (
47
+ <AbsoluteFill style={{ background: BRAND.darker, opacity }}>
48
+ <AbsoluteFill style={{ overflow: 'hidden' }}>
49
+ <FocalImage src={s.img!} focus={s.focus!} durF={s.durF} />
50
+ </AbsoluteFill>
51
+ <Vignette />
52
+ {s.label && <LabelChip text={s.label} />}
53
+ {s.caption && <Caption text={s.caption} />}
54
+ <Vo vo={s.vo} />
55
+ </AbsoluteFill>
56
+ );
57
+ };
58
+
59
+ const MobileScene: React.FC<{ s: SceneDef }> = ({ s }) => {
60
+ const opacity = useFade(s.durF);
61
+ const FRAME_H = 1000;
62
+ const FRAME_W = Math.round(FRAME_H * (1206 / 2622)); // keep phone aspect
63
+ return (
64
+ <AbsoluteFill style={{ background: BRAND.darker, opacity }}>
65
+ {/* ambient blurred backdrop from same shot */}
66
+ <AbsoluteFill>
67
+ <Img
68
+ src={staticFile('shots/' + s.img!)}
69
+ style={{
70
+ width: '100%',
71
+ height: '100%',
72
+ objectFit: 'cover',
73
+ filter: 'blur(60px) brightness(0.35)',
74
+ transform: 'scale(1.2)',
75
+ }}
76
+ />
77
+ </AbsoluteFill>
78
+ <AbsoluteFill
79
+ style={{
80
+ background:
81
+ 'radial-gradient(900px 600px at 50% 45%, rgba(7,11,20,0.2), rgba(7,11,20,0.78))',
82
+ }}
83
+ />
84
+ {/* phone */}
85
+ <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
86
+ <div
87
+ style={{
88
+ width: FRAME_W + 24,
89
+ height: FRAME_H + 24,
90
+ background: '#05070C',
91
+ borderRadius: 60,
92
+ padding: 12,
93
+ boxShadow: '0 40px 90px rgba(0,0,0,0.6)',
94
+ border: '1px solid rgba(255,255,255,0.08)',
95
+ }}
96
+ >
97
+ <div
98
+ style={{
99
+ width: FRAME_W,
100
+ height: FRAME_H,
101
+ borderRadius: 48,
102
+ overflow: 'hidden',
103
+ background: '#000',
104
+ }}
105
+ >
106
+ <FocalImage src={s.img!} focus={s.focus!} durF={s.durF} />
107
+ </div>
108
+ </div>
109
+ </AbsoluteFill>
110
+ {s.label && <LabelChip text={s.label} />}
111
+ {s.caption && <Caption text={s.caption} />}
112
+ <Vo vo={s.vo} />
113
+ </AbsoluteFill>
114
+ );
115
+ };
116
+
117
+ const TitleScene: React.FC<{ s: SceneDef }> = ({ s }) => {
118
+ const opacity = useFade(s.durF, 14, 14);
119
+ return (
120
+ <AbsoluteFill style={{ opacity }}>
121
+ <TitleCard title={s.title!} subtitle={s.subtitle!} />
122
+ <Vo vo={s.vo} />
123
+ </AbsoluteFill>
124
+ );
125
+ };
126
+
127
+ export const Scene: React.FC<{ s: SceneDef }> = ({ s }) => {
128
+ if (s.kind === 'title') return <TitleScene s={s} />;
129
+ if (s.kind === 'mobile') return <MobileScene s={s} />;
130
+ return <WebScene s={s} />;
131
+ };
@@ -0,0 +1,83 @@
1
+ import type React from 'react';
2
+ import { useCurrentFrame, useVideoConfig } from 'remotion';
3
+
4
+ // macOS-style arrow cursor, kept at constant screen size (scaled by 1/camScale)
5
+ export const Cursor: React.FC<{ x: number; y: number; camScale: number }> = ({
6
+ x,
7
+ y,
8
+ camScale,
9
+ }) => (
10
+ <div
11
+ style={{
12
+ position: 'absolute',
13
+ left: x,
14
+ top: y,
15
+ transform: `scale(${1 / camScale})`,
16
+ transformOrigin: '0 0',
17
+ pointerEvents: 'none',
18
+ zIndex: 50,
19
+ }}
20
+ >
21
+ <svg
22
+ width="32"
23
+ height="32"
24
+ viewBox="0 0 24 24"
25
+ style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
26
+ >
27
+ <path
28
+ d="M5 2 L5 19 L9.5 15 L12.5 21.5 L15 20.3 L12 14 L18 14 Z"
29
+ fill="white"
30
+ stroke="#111"
31
+ strokeWidth="1.2"
32
+ strokeLinejoin="round"
33
+ />
34
+ </svg>
35
+ </div>
36
+ );
37
+
38
+ export const Ripples: React.FC<{
39
+ clicks: { t: number; x: number; y: number }[];
40
+ camScale: number;
41
+ }> = ({ clicks, camScale }) => {
42
+ const frame = useCurrentFrame();
43
+ const { fps } = useVideoConfig();
44
+ const t = frame / fps;
45
+ return (
46
+ <>
47
+ {clicks.map((c, i) => {
48
+ const dt = t - c.t;
49
+ if (dt < 0 || dt > 0.55) return null;
50
+ const p = dt / 0.55;
51
+ const r = (16 + p * 46) / camScale;
52
+ const o = (1 - p) * 0.55;
53
+ return (
54
+ <div
55
+ key={i}
56
+ style={{
57
+ position: 'absolute',
58
+ left: c.x,
59
+ top: c.y,
60
+ width: 0,
61
+ height: 0,
62
+ pointerEvents: 'none',
63
+ zIndex: 49,
64
+ }}
65
+ >
66
+ <div
67
+ style={{
68
+ position: 'absolute',
69
+ left: -r,
70
+ top: -r,
71
+ width: r * 2,
72
+ height: r * 2,
73
+ borderRadius: '50%',
74
+ border: `${3 / camScale}px solid rgba(228,0,43,${o})`,
75
+ boxShadow: `0 0 ${12 / camScale}px rgba(228,0,43,${o})`,
76
+ }}
77
+ />
78
+ </div>
79
+ );
80
+ })}
81
+ </>
82
+ );
83
+ };
@@ -0,0 +1,68 @@
1
+ import type React from 'react';
2
+ import { AbsoluteFill, Series, Audio, Sequence, staticFile, useVideoConfig } from 'remotion';
3
+ import { BRAND } from '../scenes';
4
+ import { TitleCard } from '../ui';
5
+ import { WebSegment, MobileSegment } from './segments';
6
+ import { INTRO_DUR, WEB_DUR, MOBILE_DUR, OUTRO_DUR, FPS } from './data';
7
+
8
+ export const TOTAL_EDIT = Math.round((INTRO_DUR + WEB_DUR + MOBILE_DUR + OUTRO_DUR) * FPS);
9
+
10
+ const TitleSeg: React.FC<{ subtitle: string; vo: string; withVo: boolean }> = ({
11
+ subtitle,
12
+ vo,
13
+ withVo,
14
+ }) => (
15
+ <AbsoluteFill>
16
+ <TitleCard title="LOAD & GO" subtitle={subtitle} />
17
+ {withVo && (
18
+ <Sequence from={6}>
19
+ <Audio src={staticFile('run/vo/' + vo + '.wav')} />
20
+ </Sequence>
21
+ )}
22
+ </AbsoluteFill>
23
+ );
24
+
25
+ export type EditProps = {
26
+ withVo?: boolean;
27
+ framed?: boolean;
28
+ music?: boolean;
29
+ cursorTight?: boolean;
30
+ mobileSync?: boolean;
31
+ };
32
+
33
+ export const EditVideo: React.FC<EditProps> = ({
34
+ withVo = true,
35
+ framed = false,
36
+ music = false,
37
+ cursorTight = false,
38
+ mobileSync = false,
39
+ }) => {
40
+ const { fps } = useVideoConfig();
41
+ return (
42
+ <AbsoluteFill style={{ background: BRAND.darker }}>
43
+ {music && <Audio src={staticFile('run/music.wav')} volume={0.5} loop />}
44
+ <Series>
45
+ <Series.Sequence durationInFrames={Math.round(INTRO_DUR * fps)}>
46
+ <TitleSeg subtitle="From dispatch to delivery" vo="s0_intro" withVo={withVo} />
47
+ </Series.Sequence>
48
+ <Series.Sequence durationInFrames={Math.round(WEB_DUR * fps)}>
49
+ <WebSegment
50
+ withVo={withVo}
51
+ framed={framed}
52
+ cursorRate={cursorTight ? 1.12 : 1}
53
+ />
54
+ </Series.Sequence>
55
+ <Series.Sequence durationInFrames={Math.round(MOBILE_DUR * fps)}>
56
+ <MobileSegment withVo={withVo} sync={mobileSync} />
57
+ </Series.Sequence>
58
+ <Series.Sequence durationInFrames={Math.round(OUTRO_DUR * fps)}>
59
+ <TitleSeg
60
+ subtitle="One platform, dispatch to delivery"
61
+ vo="s8_outro"
62
+ withVo={withVo}
63
+ />
64
+ </Series.Sequence>
65
+ </Series>
66
+ </AbsoluteFill>
67
+ );
68
+ };
@@ -0,0 +1,134 @@
1
+ // Event-driven edit data — camera/cursor/clicks DERIVED from the capture events
2
+ // (so the camera tracks the grid's own horizontal scroll automatically).
3
+ import web from './web.events.json';
4
+ import mob from './mobile.events.json';
5
+ import voman from './vo-manifest.json';
6
+
7
+ export const FPS = 30;
8
+
9
+ // sequence beats so voice-overs never overlap: each starts no earlier than the previous ends
10
+ export type Beat = { start: number; dur: number; vo: string; cap: string };
11
+ function seq(raw: { at: number; vo: string; cap: string }[]): Beat[] {
12
+ const out: Beat[] = [];
13
+ let prevEnd = 0;
14
+ for (const b of raw) {
15
+ const dur = (voman as any)[b.vo]?.dur ?? 3;
16
+ const start = Math.max(b.at, prevEnd + 0.15);
17
+ out.push({ start, dur, vo: b.vo, cap: b.cap });
18
+ prevEnd = start + dur;
19
+ }
20
+ return out;
21
+ }
22
+ export const WEB_W = web.w as number;
23
+ export const WEB_H = web.h as number;
24
+ export const WEB_OFFSET = web.video_offset as number;
25
+
26
+ export function kf(t: number, ts: number[], vs: number[]): number {
27
+ if (t <= ts[0]) return vs[0];
28
+ if (t >= ts[ts.length - 1]) return vs[vs.length - 1];
29
+ let i = 0;
30
+ while (i < ts.length - 1 && t > ts[i + 1]) i++;
31
+ const span = ts[i + 1] - ts[i] || 1;
32
+ const p = (t - ts[i]) / span;
33
+ const sp = p * p * (3 - 2 * p); // smoothstep ease
34
+ return vs[i] + (vs[i + 1] - vs[i]) * sp;
35
+ }
36
+
37
+ type Ev = { t: number; kind: string; x: number | null; y: number | null; label: string; box: any };
38
+
39
+ // Phase-based camera: hold a region per "scene" phase (robust to A/V drift),
40
+ // while the cursor still follows each individual action.
41
+ function buildWeb() {
42
+ const evs = web.events as Ev[];
43
+ const scenes = evs.filter((e) => e.kind === 'scene');
44
+ const actions = evs.filter((e) => e.x != null && e.y != null);
45
+ const lastActT = actions[actions.length - 1].t;
46
+ const phases = scenes.map((s, i) => ({
47
+ t0: s.t,
48
+ t1: i + 1 < scenes.length ? scenes[i + 1].t : lastActT + 1.0,
49
+ }));
50
+
51
+ const camT = [0],
52
+ camS = [1.08],
53
+ camX = [WEB_W / 2],
54
+ camY = [WEB_H / 2];
55
+ for (const ph of phases) {
56
+ const acts = actions.filter((a) => a.t >= ph.t0 - 0.05 && a.t < ph.t1);
57
+ if (!acts.length) continue;
58
+ let x0 = 1e9,
59
+ y0 = 1e9,
60
+ x1 = -1e9,
61
+ y1 = -1e9;
62
+ for (const a of acts) {
63
+ const b = a.box || { x: (a.x as number) - 90, y: (a.y as number) - 20, w: 180, h: 40 };
64
+ x0 = Math.min(x0, b.x);
65
+ y0 = Math.min(y0, b.y);
66
+ x1 = Math.max(x1, b.x + b.w);
67
+ y1 = Math.max(y1, b.y + b.h);
68
+ }
69
+ const cx = (x0 + x1) / 2,
70
+ cy = (y0 + y1) / 2;
71
+ const bw = Math.max(1, x1 - x0),
72
+ bh = Math.max(1, y1 - y0);
73
+ const s = Math.min(1.95, Math.max(1.3, Math.min((WEB_W * 0.6) / bw, (WEB_H * 0.6) / bh)));
74
+ camT.push(ph.t0 + 0.6);
75
+ camS.push(s);
76
+ camX.push(cx);
77
+ camY.push(cy);
78
+ camT.push(ph.t1 - 0.2);
79
+ camS.push(s);
80
+ camX.push(cx);
81
+ camY.push(cy);
82
+ }
83
+
84
+ // cursor HOLDS at each field (during typing), then snaps to the next just before it acts
85
+ const curT = [0],
86
+ curX = [WEB_W / 2],
87
+ curY = [320];
88
+ const clicks: { t: number; x: number; y: number }[] = [];
89
+ for (let i = 0; i < actions.length; i++) {
90
+ const a = actions[i];
91
+ curT.push(a.t);
92
+ curX.push(a.x as number);
93
+ curY.push(a.y as number);
94
+ if (a.kind === 'click') clicks.push({ t: a.t, x: a.x as number, y: a.y as number });
95
+ const next = actions[i + 1];
96
+ if (next) {
97
+ const hold = Math.max(a.t + 0.05, next.t - 0.4);
98
+ curT.push(hold);
99
+ curX.push(a.x as number);
100
+ curY.push(a.y as number);
101
+ }
102
+ }
103
+ const dur = phases[phases.length - 1].t1 + 2.2;
104
+ return { camT, camS, camX, camY, curT, curX, curY, clicks, dur };
105
+ }
106
+
107
+ export const WEB = buildWeb();
108
+ export const WEB_DUR = WEB.dur;
109
+
110
+ export const WEB_BEATS = seq([
111
+ { at: 0.3, vo: 's1_daysheet', cap: 'The Day Sheet — every job in one grid' },
112
+ { at: 4.2, vo: 's2_create', cap: 'Add a job inline — address lookup as you type' },
113
+ { at: 12.9, vo: 's3_details', cap: 'Site contacts & job details, in the row' },
114
+ { at: 18.4, vo: 's4_crew', cap: 'Allocate the crew — driver & truck' },
115
+ { at: 25.1, vo: 's5_publish', cap: 'Publish — sent to the driver’s phone' },
116
+ ]);
117
+
118
+ export const MOBILE_DUR = 10.0;
119
+ export const MOBILE_W = mob.w as number;
120
+ export const MOBILE_H = mob.h as number;
121
+ export const MOBILE_BEATS = seq([
122
+ { at: 0.8, vo: 's6_push', cap: 'On the truck — an instant notification' },
123
+ { at: 4.8, vo: 's7_jobdet', cap: 'The full job: pickup, delivery, load, contacts' },
124
+ ]);
125
+ // event-driven mobile camera (banner -> pull out -> job details), in 1206x2622 coords
126
+ export const MOBILE_CAM = {
127
+ T: [0, 1.3, 4.0, 5.6, 8.0, MOBILE_DUR],
128
+ S: [1.0, 1.55, 1.55, 1.0, 1.16, 1.16],
129
+ X: [603, 603, 603, 603, 603, 603],
130
+ Y: [760, 250, 250, 1120, 1280, 1280],
131
+ };
132
+
133
+ export const INTRO_DUR = 3.0;
134
+ export const OUTRO_DUR = 3.6;
@@ -0,0 +1,11 @@
1
+ {
2
+ "video": "raw/mobile.mov",
3
+ "w": 1206,
4
+ "h": 2622,
5
+ "events": [
6
+ { "t": 0.0, "kind": "scene", "label": "home" },
7
+ { "t": 1.2, "kind": "notify", "x": 603, "y": 150, "label": "push banner" },
8
+ { "t": 5.0, "kind": "scene", "label": "open" },
9
+ { "t": 8.0, "kind": "scene", "label": "jobdetails" }
10
+ ]
11
+ }