simframe 0.18.0 → 0.19.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.
@@ -45,13 +45,19 @@ public final class CoreSimulatorPlatform: SimulatorPlatform {
45
45
  private static func loadFrameworks() throws {
46
46
  guard !loaded else { return }
47
47
  let developerDir = Self.developerDir()
48
- let candidates = [
49
- ("CoreSimulator", "/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"),
50
- ("SimulatorKit", "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"),
48
+ let candidates: [(String, [String])] = [
49
+ ("CoreSimulator", ["/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"]),
50
+ ("SimulatorKit", [
51
+ "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
52
+ // Xcode moved this out of Contents/Developer into the top-level
53
+ // Contents/SharedFrameworks at some point around Xcode 16-27;
54
+ // keep the old path first since it's still what older Xcodes use.
55
+ "\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
56
+ ]),
51
57
  ]
52
- for (name, path) in candidates {
53
- guard FileManager.default.fileExists(atPath: path) else {
54
- throw PrivateAPIError.frameworksUnavailable("\(name) not found at \(path)")
58
+ for (name, paths) in candidates {
59
+ guard let path = paths.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
60
+ throw PrivateAPIError.frameworksUnavailable("\(name) not found at \(paths.joined(separator: " or "))")
55
61
  }
56
62
  guard let handle = dlopen(path, RTLD_NOW) else {
57
63
  let reason = String(cString: dlerror())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simframe",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "mcpName": "io.github.lvlrSajjad/simframe",
5
5
  "description": "Always-warm iOS Simulator and Android emulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
6
6
  "keywords": [
@@ -21,9 +21,17 @@
21
21
  // anything it does not recognise rather than dropping it — a converter that
22
22
  // silently skips an unfamiliar tag produces a document that looks complete and
23
23
  // is not, which is the failure mode this whole file exists to prevent.
24
+ //
25
+ // That promise had a hole in it for as long as it existed, and the hole was
26
+ // not an unfamiliar tag but the space between two familiar ones. The scanner
27
+ // matched a list of constructs and stepped over everything in between without
28
+ // looking, so the colophon's body — a text node the page puts straight inside
29
+ // its container, with no `<p>` around it — was not unhandled, it was unseen,
30
+ // and the document ended on a bare **On the numbers** label. Refusing what you
31
+ // do not recognise is only half of it; you have to look everywhere first.
24
32
  import fs from 'node:fs';
25
33
  import path from 'node:path';
26
- import { fileURLToPath } from 'node:url';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
35
 
28
36
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
37
  const SOURCE = path.join(ROOT, 'docs', 'agents-shouldnt-blink.html');
@@ -81,12 +89,36 @@ function table(html) {
81
89
  ];
82
90
  }
83
91
 
84
- /** Everything inside one `<section>`, in order. */
92
+ /**
93
+ * Tags that hold blocks without being one. The scanner walks through these; a
94
+ * tag in a gap that is *not* one of them is content, and `inline` throws on it.
95
+ */
96
+ const CONTAINER = /<\/?(?:div|ul|ol|section|header|figure)\b[^>]*>/g;
97
+
98
+ /**
99
+ * What the scanner stepped over between two blocks. Containers are expected.
100
+ * Anything left is a text node the page put straight inside a container rather
101
+ * than in a `<p>` — the colophon's body is one — and it is content like any
102
+ * other. Nothing looked in the gaps before, so every generated copy of this
103
+ * document ended on a dangling **On the numbers** label with its paragraph
104
+ * gone: exactly the silent-drop failure the rest of this file refuses.
105
+ */
106
+ function gap(html) {
107
+ return html
108
+ .replace(/<!--[\s\S]*?-->/g, '')
109
+ .split(CONTAINER)
110
+ .map((piece) => piece.trim())
111
+ .filter(Boolean)
112
+ .map((text) => ({ kind: 'p', text: inline(text) }));
113
+ }
114
+
115
+ /** Everything inside one `<section>` (or the `<header>`), in order. */
85
116
  function blocks(html) {
86
117
  const out = [];
87
118
  // One pass over the constructs the article actually uses. `lastIndex`
88
119
  // walking rather than nested parsing, because the document is flat inside a
89
- // section and a real parser here would be a dependency.
120
+ // section and a real parser here would be a dependency. What the pass does
121
+ // *not* match is handed to `gap` rather than skipped.
90
122
  const re = new RegExp([
91
123
  '<h([123])>([\\s\\S]*?)<\\/h\\1>',
92
124
  '<p class="sec-label">([\\s\\S]*?)<\\/p>',
@@ -96,10 +128,15 @@ function blocks(html) {
96
128
  '<table>([\\s\\S]*?)<\\/table>',
97
129
  '<pre>([\\s\\S]*?)<\\/pre>',
98
130
  '<li>([\\s\\S]*?)<\\/li>',
131
+ '<figcaption>([\\s\\S]*?)<\\/figcaption>',
132
+ '<svg\\b([^>]*)>[\\s\\S]*?<\\/svg>',
99
133
  ].join('|'), 'g');
100
134
  let m;
135
+ let cursor = 0;
101
136
  while ((m = re.exec(html)) !== null) {
102
- const [, level, heading, sec, panel, amend, para, tbl, pre, li] = m;
137
+ out.push(...gap(html.slice(cursor, m.index)));
138
+ cursor = re.lastIndex;
139
+ const [, level, heading, sec, panel, amend, para, tbl, pre, li, caption, svg] = m;
103
140
  if (heading != null) out.push({ kind: 'h', level: Number(level), text: inline(heading) });
104
141
  else if (sec != null) out.push({ kind: 'kicker', text: inline(sec) });
105
142
  else if (panel != null) out.push({ kind: 'label', text: inline(panel) });
@@ -108,17 +145,65 @@ function blocks(html) {
108
145
  else if (tbl != null) out.push({ kind: 'table', lines: table(tbl) });
109
146
  else if (pre != null) out.push({ kind: 'pre', text: decode(stripTagsPreserving(pre)) });
110
147
  else if (li != null) out.push({ kind: 'li', text: inline(li) });
148
+ else if (caption != null) out.push({ kind: 'p', text: inline(caption) });
149
+ // A diagram cannot be projected into Markdown, but its `aria-label` is
150
+ // already the prose a screen reader gets, so that is what a reader of the
151
+ // Markdown gets too. An undescribed diagram is content this cannot carry,
152
+ // so it throws rather than quietly becoming nothing.
153
+ else if (svg != null) {
154
+ const label = svg.match(/aria-label="([^"]*)"/);
155
+ if (!label) throw new Error('<svg> with no aria-label — nothing to project');
156
+ out.push({ kind: 'figure', text: inline(label[1]) });
157
+ }
111
158
  }
159
+ out.push(...gap(html.slice(cursor)));
112
160
  return out;
113
161
  }
114
162
 
115
163
  /** `<pre>` keeps its newlines; only tags come out. */
116
164
  const stripTagsPreserving = (s) => s.replace(/<[^>]+>/g, '').replace(/^\n/, '').replace(/\s+$/, '');
117
165
 
166
+ /** One section's or the header's blocks, appended to the document. */
167
+ function emit(lines, bs) {
168
+ let pendingKicker = null;
169
+ let inList = false;
170
+ for (const b of bs) {
171
+ if (b.kind !== 'li' && inList) { lines.push(''); inList = false; }
172
+ switch (b.kind) {
173
+ // The kicker precedes its heading on the page and reads as a label for
174
+ // it, so it is held until the heading arrives rather than emitted where
175
+ // it was found.
176
+ case 'kicker': pendingKicker = b.text; break;
177
+ case 'h':
178
+ if (pendingKicker) { lines.push(`*${pendingKicker}*`, ''); pendingKicker = null; }
179
+ // `<h1>` is the title, already emitted above, so the page's `<h2>`
180
+ // sections are the document's second level and not its third.
181
+ lines.push(`${'#'.repeat(b.level)} ${b.text}`, '');
182
+ break;
183
+ case 'label': lines.push(`**${b.text}**`, ''); break;
184
+ case 'p': lines.push(b.text, ''); break;
185
+ case 'figure': lines.push(`*Figure — ${b.text}*`, ''); break;
186
+ case 'li': lines.push(`- ${b.text}`); inList = true; break;
187
+ case 'table': lines.push(...b.lines, ''); break;
188
+ case 'pre': lines.push('```', b.text, '```', ''); break;
189
+ default: throw new Error(`unhandled block ${b.kind}`);
190
+ }
191
+ }
192
+ if (inList) lines.push('');
193
+ }
194
+
118
195
  export function render(html) {
119
196
  const title = stripTags(html.match(/<h1>([\s\S]*?)<\/h1>/)[1]);
120
197
  const kicker = stripTags(html.match(/<p class="kicker">([\s\S]*?)<\/p>/)[1]);
121
- const standfirst = html.match(/<p class="standfirst">([\s\S]*?)<\/p>/);
198
+ // The rest of the header: the deck, the figure, the provenance line. This
199
+ // used to look for `<p class="standfirst">`, a class the page does not have,
200
+ // so the match was always null and the branch that emitted it never ran —
201
+ // the same silent drop as the colophon, arrived at from the other end. The
202
+ // header is walked with the section machinery now, minus the two blocks
203
+ // emitted by hand above.
204
+ const header = (html.match(/<header>([\s\S]*?)<\/header>/)?.[1] ?? '')
205
+ .replace(/<h1>[\s\S]*?<\/h1>/, '')
206
+ .replace(/<p class="kicker">[\s\S]*?<\/p>/, '');
122
207
 
123
208
  const lines = [
124
209
  `# ${title}`,
@@ -134,52 +219,33 @@ export function render(html) {
134
219
  '> in [`EXPERIMENTS.md`](EXPERIMENTS.md).',
135
220
  '',
136
221
  ];
137
- if (standfirst) lines.push(inline(standfirst[1]), '');
222
+ emit(lines, blocks(header));
138
223
  lines.push('---', '');
139
224
 
140
- for (const section of html.match(/<section>[\s\S]*?<\/section>/g) ?? []) {
141
- let pendingKicker = null;
142
- let inList = false;
143
- for (const b of blocks(section)) {
144
- if (b.kind !== 'li' && inList) { lines.push(''); inList = false; }
145
- switch (b.kind) {
146
- // The kicker precedes its heading on the page and reads as a label for
147
- // it, so it is held until the heading arrives rather than emitted where
148
- // it was found.
149
- case 'kicker': pendingKicker = b.text; break;
150
- case 'h':
151
- if (pendingKicker) { lines.push(`*${pendingKicker}*`, ''); pendingKicker = null; }
152
- // `<h1>` is the title, already emitted above, so the page's `<h2>`
153
- // sections are the document's second level and not its third.
154
- lines.push(`${'#'.repeat(b.level)} ${b.text}`, '');
155
- break;
156
- case 'label': lines.push(`**${b.text}**`, ''); break;
157
- case 'p': lines.push(b.text, ''); break;
158
- case 'li': lines.push(`- ${b.text}`); inList = true; break;
159
- case 'table': lines.push(...b.lines, ''); break;
160
- case 'pre': lines.push('```', b.text, '```', ''); break;
161
- default: throw new Error(`unhandled block ${b.kind}`);
162
- }
163
- }
164
- if (inList) lines.push('');
165
- }
225
+ for (const section of html.match(/<section>[\s\S]*?<\/section>/g) ?? []) emit(lines, blocks(section));
166
226
 
167
227
  return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
168
228
  }
169
229
 
170
- const html = fs.readFileSync(SOURCE, 'utf8');
171
- const md = render(html);
230
+ // Only as a command. This used to run on import, which meant the test that
231
+ // asserts the page and the Markdown are one document regenerated the Markdown
232
+ // before reading it back and compared the output to itself. It could not fail,
233
+ // and it did not — the colophon's missing paragraph sat under a green suite
234
+ // for as long as it existed. `--check` in CI was the only real gate.
235
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
236
+ const md = render(fs.readFileSync(SOURCE, 'utf8'));
172
237
 
173
- if (process.argv.includes('--check')) {
174
- const current = fs.existsSync(TARGET) ? fs.readFileSync(TARGET, 'utf8') : '';
175
- if (current === md) {
176
- console.log(`docs/ARTICLE.md is in step with the page (${md.split('\n').length} lines)`);
177
- process.exit(0);
238
+ if (process.argv.includes('--check')) {
239
+ const current = fs.existsSync(TARGET) ? fs.readFileSync(TARGET, 'utf8') : '';
240
+ if (current === md) {
241
+ console.log(`docs/ARTICLE.md is in step with the page (${md.split('\n').length} lines)`);
242
+ process.exit(0);
243
+ }
244
+ console.error('docs/ARTICLE.md is out of date with docs/agents-shouldnt-blink.html.');
245
+ console.error('Run: node scripts/article-md.mjs');
246
+ process.exit(1);
178
247
  }
179
- console.error('docs/ARTICLE.md is out of date with docs/agents-shouldnt-blink.html.');
180
- console.error('Run: node scripts/article-md.mjs');
181
- process.exit(1);
182
- }
183
248
 
184
- fs.writeFileSync(TARGET, md);
185
- console.log(`wrote docs/ARTICLE.md — ${md.split('\n').length} lines from ${SOURCE.split('/').pop()}`);
249
+ fs.writeFileSync(TARGET, md);
250
+ console.log(`wrote docs/ARTICLE.md — ${md.split('\n').length} lines from ${SOURCE.split('/').pop()}`);
251
+ }
@@ -21,6 +21,7 @@ import { runScript } from '../src/actions.js';
21
21
  import * as api from '../src/index.js';
22
22
  import * as baseline from '../src/baseline.js';
23
23
  import * as metrics from '../src/metrics.js';
24
+ import * as wedge from '../src/wedge.js';
24
25
 
25
26
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
27
  const arg = (name, fallback = null) => {
@@ -55,8 +56,22 @@ const passes = Math.max(1, Number(arg('passes', gate ? '3' : '1')));
55
56
  * asked to do it degrades and then stops rendering. The suite paces itself so
56
57
  * the numbers describe simframe rather than the simulator's tolerance for
57
58
  * being hammered.
59
+ *
60
+ * **1500 was not enough, and that is measured rather than argued.** Same flow,
61
+ * same device, same afternoon, varying only this number: at 1500 ms the device
62
+ * stopped being readable after 4, 4 and 5 runs across three separate attempts;
63
+ * at 4000-5000 ms a full 8-run pass finished with `diagnose` still saying
64
+ * `healthy`. That is a large part of item 173 — "the bench suite wedges the
65
+ * device it measures" — and it means the suite has never once completed a full
66
+ * pass on a hosted runner.
67
+ *
68
+ * Raising it is a measurement change, not a tuning tweak: **every HPI number in
69
+ * this repo recorded before 2026-09-18 was taken at 1500**, including the
70
+ * committed reference, so numbers across that line are not comparable. The
71
+ * reference was re-recorded at the new default for exactly that reason, and
72
+ * `docs/BENCHMARKS.md` says so.
58
73
  */
59
- const cooldownMs = Math.max(0, Number(arg('cooldown', '1500')));
74
+ const cooldownMs = Math.max(0, Number(arg('cooldown', '4000')));
60
75
 
61
76
  const suite = baseline.loadSuite(arg('suite', baseline.SUITE_FILE)).filter((f) => !only || f.name === only);
62
77
  if (!suite.length) {
@@ -101,6 +116,35 @@ outer: for (let pass = 1; pass <= passes; pass += 1) {
101
116
  const thisPass = new Set();
102
117
  passSets.push(thisPass);
103
118
  if (passes > 1) console.log(`pass ${pass}/${passes}`);
119
+ // **Recover between passes, not only after a failure.**
120
+ //
121
+ // Measured 2026-09-18: this device tolerates roughly 8-10 app launches before
122
+ // it stops being readable, and it degrades *within* a pass — the suite's own
123
+ // second flow went 9 s, 18 s, 30 s, then two incompletes, on a device that had
124
+ // run the same flow five times cleanly minutes earlier. `passes x runs` is 30
125
+ // runs at the default and 18 in CI, both far past that, with no recovery in
126
+ // between. So a multi-pass suite has never been physically completable and the
127
+ // gate has never produced a hosted-runner reading in this whole series.
128
+ //
129
+ // DEFERRED 173 listed this as worth trying. Nothing had tried it.
130
+ //
131
+ // Diagnosed first, revived only if needed: a revive is ~40 s and paying it
132
+ // when the device is fine would be spending the budget on superstition. The
133
+ // diagnosis is also the honest record of what state each pass started in —
134
+ // without it, a pass that began on a half-dead device is indistinguishable
135
+ // from one that did not.
136
+ if (pass > 1) {
137
+ const before = wedge.classify(await wedge.snapshot(dev.udid).catch(() => null));
138
+ console.log(` device before pass ${pass}: ${before?.state ?? 'unreadable'}`);
139
+ if (before?.state !== 'healthy') {
140
+ const revived = await wedge.revive(dev.udid, { device: dev });
141
+ console.log(` revived between passes — now ${revived.state}`);
142
+ if (!revived.usable) {
143
+ abort = { kind: 'the device could not be revived between passes', message: revived.verdict?.detail ?? '' };
144
+ break outer;
145
+ }
146
+ }
147
+ }
104
148
  for (const flow of suite) {
105
149
  for (let run = 1; run <= runs; run += 1) {
106
150
  // The same start state the human baseline was recorded from: app not
@@ -678,23 +678,50 @@ if (contradicted) {
678
678
  // --force is the deliberate override, and it is what lets the replay machinery
679
679
  // be tested without waiting on a screen that may never settle into one shape.
680
680
  const forced = await jsonRetry(['do', LOOP, `--save=${FLOW_NAME}`, '--force'], { allowFail: true });
681
- if (check(forced.saved?.ok === true, 'and --force saves it anyway', `${forced.saved?.steps} steps`)) {
681
+ if (check(forced.saved?.ok === true, 'and --force saves it anyway',
682
+ `${forced.saved?.steps} steps, provisional=${forced.saved?.provisional}`)) {
682
683
  const listed = await jsonRetry(['flow', 'list']);
683
- check(listed.some((f) => f.name === FLOW_NAME), 'the saved flow is listed');
684
+ const before = listed.find((f) => f.name === FLOW_NAME);
685
+ check(Boolean(before), 'the saved flow is listed');
686
+ // Whether there is anything to promote is a fact about *this* save, not the
687
+ // first attempt's. The forced save is another traversal of the same loop,
688
+ // and by then the graph has seen every edge in it: measured on the bench
689
+ // device, the first run saved `provisional=true` and every run after it
690
+ // saved confirmed outright. Both checks below used to assume a provisional
691
+ // flow — so the positive one passed on a flow that had never been
692
+ // provisional, and the negative one failed CI on it — main was red on a
693
+ // precondition the harness never established, while the product did exactly
694
+ // what it should.
695
+ const wasProvisional = before?.provisional === true;
684
696
  const replayed = await jsonRetry(['flow', 'run', FLOW_NAME], { allowFail: true });
697
+ // A replay that stopped early says which step stopped it and why. 1dcce18's
698
+ // red run printed `1/2 steps` and nothing else, so the one fact that would
699
+ // say whether the replay itself is broken was never on the page.
700
+ const stoppedAt = (replayed.results ?? []).find((r) => !r.ok);
685
701
  check(replayed.ranSteps >= 1 && Array.isArray(replayed.results),
686
702
  'and replays from disk with no model in the loop',
687
- `${replayed.ranSteps}/${replayed.totalSteps} steps`);
703
+ `${replayed.ranSteps}/${replayed.totalSteps} steps`
704
+ + (stoppedAt ? `; stopped at [${stoppedAt.index}] ${stoppedAt.action}: ${String(stoppedAt.detail ?? '').slice(0, 160)}` : ''));
688
705
  // The other half of the bootstrap, and the reason "provisional" is not a
689
706
  // state nothing ever leaves: a clean replay is the confirmation a first
690
707
  // traversal could not give. Only asserted when the replay actually ran to
691
708
  // the end — a partial replay promotes nothing, deliberately.
692
709
  const after = await jsonRetry(['flow', 'list']);
693
710
  const entry = after.find((f) => f.name === FLOW_NAME);
694
- if (replayed.ok) {
711
+ // Whatever the replay did, it must not lose the flow. Neither branch below
712
+ // could say this: an entry that vanished and an entry without the field both
713
+ // read `undefined`.
714
+ check(Boolean(entry), 'a replay leaves the flow it replayed on disk',
715
+ entry ? '' : 'the entry is gone from the listing');
716
+ if (!wasProvisional) {
717
+ skip(replayed.ok
718
+ ? 'and a clean replay confirms a provisional flow'
719
+ : 'and a replay that failed does NOT confirm the flow it just disproved',
720
+ 'the forced save verified every step, so the flow was confirmed before the replay and there was nothing to promote');
721
+ } else if (replayed.ok) {
695
722
  check(entry && !entry.provisional,
696
723
  'and a clean replay confirms a provisional flow',
697
- `provisional=${entry?.provisional ?? 'gone'}`);
724
+ `provisional=${entry ? entry.provisional : 'gone'}`);
698
725
  } else {
699
726
  // The other direction, and it is the one that was silently wrong: promotion
700
727
  // keyed on `ranSteps === steps.length`, which a failure on the *last* step
@@ -703,7 +730,7 @@ if (check(forced.saved?.ok === true, 'and --force saves it anyway', `${forced.sa
703
730
  // confirmation, so the negative case has to be checked too.
704
731
  check(entry?.provisional === true,
705
732
  'and a replay that failed does NOT confirm the flow it just disproved',
706
- `ok=${replayed.ok}, provisional=${entry?.provisional}`);
733
+ `ok=${replayed.ok}, provisional=${entry ? entry.provisional : 'gone'}`);
707
734
  }
708
735
  const unknown = await cli(['flow', 'run', 'no-such-flow'], { expectFail: true });
709
736
  check(/no flow/i.test(unknown), 'an unknown flow name is refused with what is known');
@@ -0,0 +1,36 @@
1
+ # The README GIF, and how to remake it
2
+
3
+ `docs/simframe-demo.gif` is a `simctl` recording of the benchmark simulator
4
+ composed with the flow's own JSON verdicts. No dependency was added: the
5
+ compositor is one Swift file on AVFoundation, CoreText and ImageIO.
6
+
7
+ 1. Boot a simulator, launch Settings so the flow starts at its root, then
8
+ record while the flow runs and keep the wall-clock bounds:
9
+
10
+ ```bash
11
+ D=<udid>
12
+ xcrun simctl launch $D com.apple.Preferences; sleep 4
13
+ xcrun simctl io $D recordVideo --codec h264 --force take.mp4 & REC=$!; sleep 4
14
+ T0=$(python3 -c 'import time;print(int(time.time()*1000))')
15
+ simframe do scripts/demo-gif/flow.json --device=$D --json > do.json
16
+ T1=$(python3 -c 'import time;print(int(time.time()*1000))')
17
+ sleep 2; kill -INT $REC; wait $REC
18
+ ```
19
+
20
+ Record with the app already launched: a `launch` step inside the recording
21
+ took 8–10 s on two takes because the launch did not front on the first
22
+ attempt while `recordVideo` was attached, against 89 ms without it.
23
+
24
+ 2. Build `events.json` from `do.json`, `T0` and `T1` — the shape is in
25
+ `events.example.json`: every `t` is an epoch millisecond, and
26
+ `videoEndEpochMs` is `T1 + 2000`, which is how the compositor finds where
27
+ the recording starts without a timestamp from `simctl`.
28
+
29
+ 3. Compose:
30
+
31
+ ```bash
32
+ swift scripts/demo-gif/compose.swift take.mp4 events.json docs/simframe-demo.gif
33
+ ```
34
+
35
+ 760×560, 10 fps, the last frame held two seconds; the example take is
36
+ 85 frames and 336 KB.
@@ -0,0 +1,106 @@
1
+ // Composes a README GIF: simulator recording on the left, a terminal panel on
2
+ // the right whose lines appear at the moments the flow printed them.
3
+ // usage: swift gif.swift <video.mp4> <events.json> <out.gif>
4
+ // events.json: {"startMs":..,"endMs":..,"fps":..,"lines":[{"t":ms,"text":".."}]}
5
+ // Times are milliseconds from the start of the video. No dependencies.
6
+ import Foundation
7
+ import AVFoundation
8
+ import ImageIO
9
+ import CoreText
10
+ import UniformTypeIdentifiers
11
+
12
+ struct Line: Decodable { let t: Double; let text: String; let color: String? }
13
+ struct Events: Decodable { let startMs: Double; let endMs: Double; let fps: Double; let lines: [Line]; let title: String?; let videoEndEpochMs: Double }
14
+
15
+ let args = CommandLine.arguments
16
+ let video = URL(fileURLWithPath: args[1])
17
+ let raw = try! JSONDecoder().decode(Events.self, from: Data(contentsOf: URL(fileURLWithPath: args[2])))
18
+ let asset0 = AVURLAsset(url: video)
19
+ let durMs = CMTimeGetSeconds(asset0.duration) * 1000
20
+ let vstart = raw.videoEndEpochMs - durMs
21
+ struct Ev { let startMs: Double; let endMs: Double; let fps: Double; let lines: [Line]; let title: String? }
22
+ let events = Ev(startMs: max(0, raw.startMs - vstart), endMs: min(durMs, raw.endMs - vstart), fps: raw.fps,
23
+ lines: raw.lines.map { Line(t: $0.t - vstart, text: $0.text, color: $0.color) }, title: raw.title)
24
+ print("video \(durMs) ms, window \(events.startMs)...\(events.endMs)")
25
+ let out = URL(fileURLWithPath: args[3])
26
+
27
+ let asset = AVURLAsset(url: video)
28
+ let gen = AVAssetImageGenerator(asset: asset)
29
+ gen.requestedTimeToleranceBefore = .zero
30
+ gen.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
31
+ gen.appliesPreferredTrackTransform = true
32
+
33
+ let W = 760, H = 560, pad = 16
34
+ let simH = H - 2 * pad
35
+ let firstFrame = try! gen.copyCGImage(at: CMTime(seconds: events.startMs / 1000, preferredTimescale: 600), actualTime: nil)
36
+ let simW = Int(Double(simH) * Double(firstFrame.width) / Double(firstFrame.height))
37
+ let termX = pad + simW + pad
38
+ let termW = W - termX - pad
39
+
40
+ func color(_ hex: String) -> CGColor {
41
+ var v: UInt64 = 0; Scanner(string: String(hex.dropFirst())).scanHexInt64(&v)
42
+ return CGColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255, green: CGFloat((v >> 8) & 0xff) / 255, blue: CGFloat(v & 0xff) / 255, alpha: 1)
43
+ }
44
+ let bg = color("#0b0f14"), termBg = color("#111820"), fg = color("#d8dee6"), dim = color("#7d8590"), ok = color("#3fb950"), accent = color("#79c0ff")
45
+ let font = CTFontCreateWithName("Menlo" as CFString, 12.5, nil)
46
+ let boldFont = CTFontCreateWithName("Menlo-Bold" as CFString, 12.5, nil)
47
+
48
+ func wrap(_ s: String, width: CGFloat, font: CTFont) -> [String] {
49
+ // greedy word wrap measured with the real font
50
+ var lines: [String] = []; var cur = ""
51
+ for word in s.split(separator: " ", omittingEmptySubsequences: false) {
52
+ let cand = cur.isEmpty ? String(word) : cur + " " + word
53
+ let l = CTLineCreateWithAttributedString(NSAttributedString(string: cand, attributes: [kCTFontAttributeName as NSAttributedString.Key: font]))
54
+ if CTLineGetTypographicBounds(l, nil, nil, nil) > Double(width) && !cur.isEmpty { lines.append(cur); cur = String(word) } else { cur = cand }
55
+ }
56
+ if !cur.isEmpty { lines.append(cur) }
57
+ return lines
58
+ }
59
+
60
+ func draw(ctx: CGContext, text: String, x: CGFloat, y: CGFloat, color: CGColor, font: CTFont) {
61
+ let l = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: [kCTFontAttributeName as NSAttributedString.Key: font, kCTForegroundColorAttributeName as NSAttributedString.Key: color]))
62
+ ctx.textPosition = CGPoint(x: x, y: y); CTLineDraw(l, ctx)
63
+ }
64
+
65
+ func render(at ms: Double) -> CGImage {
66
+ let cs = CGColorSpace(name: CGColorSpace.sRGB)!
67
+ let ctx = CGContext(data: nil, width: W, height: H, bitsPerComponent: 8, bytesPerRow: 0, space: cs, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
68
+ ctx.setFillColor(bg); ctx.fill(CGRect(x: 0, y: 0, width: W, height: H))
69
+ // simulator frame, rounded corners
70
+ let frame = (try? gen.copyCGImage(at: CMTime(seconds: ms / 1000, preferredTimescale: 600), actualTime: nil)) ?? firstFrame
71
+ let simRect = CGRect(x: pad, y: pad, width: simW, height: simH)
72
+ ctx.saveGState()
73
+ ctx.addPath(CGPath(roundedRect: simRect, cornerWidth: 22, cornerHeight: 22, transform: nil)); ctx.clip()
74
+ ctx.draw(frame, in: simRect)
75
+ ctx.restoreGState()
76
+ // terminal panel
77
+ let termRect = CGRect(x: termX, y: pad, width: termW, height: simH)
78
+ ctx.setFillColor(termBg); ctx.addPath(CGPath(roundedRect: termRect, cornerWidth: 10, cornerHeight: 10, transform: nil)); ctx.fillPath()
79
+ var y = CGFloat(H - pad - 28)
80
+ let lx = CGFloat(termX + 14), lw = CGFloat(termW - 28)
81
+ if let t = events.title { draw(ctx: ctx, text: t, x: lx, y: y, color: dim, font: font); y -= 26 }
82
+ for line in events.lines where line.t <= ms {
83
+ let c: CGColor = line.color == "ok" ? ok : line.color == "accent" ? accent : line.color == "dim" ? dim : fg
84
+ let f = line.color == "accent" ? boldFont : font
85
+ for (i, piece) in wrap(line.text, width: lw, font: f).enumerated() {
86
+ draw(ctx: ctx, text: piece, x: lx + (i > 0 ? 28 : 0), y: y, color: c, font: f); y -= 18
87
+ }
88
+ y -= 6
89
+ }
90
+ // cursor
91
+ let blink = Int(ms / 500) % 2 == 0
92
+ if blink { ctx.setFillColor(fg); ctx.fill(CGRect(x: lx, y: y + 2, width: 8, height: 14)) }
93
+ return ctx.makeImage()!
94
+ }
95
+
96
+ let n = Int((events.endMs - events.startMs) / 1000 * events.fps)
97
+ let dest = CGImageDestinationCreateWithURL(out as CFURL, UTType.gif.identifier as CFString, n, nil)!
98
+ CGImageDestinationSetProperties(dest, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]] as CFDictionary)
99
+ for i in 0..<n {
100
+ let ms = events.startMs + Double(i) * 1000 / events.fps
101
+ let img = render(at: ms)
102
+ let delay = (i == n - 1) ? 2.0 : 1 / events.fps
103
+ CGImageDestinationAddImage(dest, img, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: delay, kCGImagePropertyGIFUnclampedDelayTime: delay]] as CFDictionary)
104
+ }
105
+ CGImageDestinationFinalize(dest)
106
+ print("wrote \(out.path) \(n) frames \(W)x\(H)")
@@ -0,0 +1,74 @@
1
+ {
2
+ "startMs": 1790339118840,
3
+ "endMs": 1790339128578,
4
+ "fps": 10,
5
+ "videoEndEpochMs": 1790339127378,
6
+ "title": "iPhone 17 Pro \u00b7 iOS 26.5 \u00b7 one call from the agent",
7
+ "lines": [
8
+ {
9
+ "t": 1790339119740,
10
+ "text": "$ simframe do flow.json",
11
+ "color": "accent"
12
+ },
13
+ {
14
+ "t": 1790339122213,
15
+ "text": "ok [0] tap: tapped \"Accessibility\" at 201,380",
16
+ "color": "ok"
17
+ },
18
+ {
19
+ "t": 1790339123369,
20
+ "text": "ok [1] tap: tapped \"Display & Text Size\" at 201,504",
21
+ "color": "ok"
22
+ },
23
+ {
24
+ "t": 1790339124840,
25
+ "text": "ok [2] tap: tapped \"Larger Text\" at 201,216",
26
+ "color": "ok"
27
+ },
28
+ {
29
+ "t": 1790339125080,
30
+ "text": "ok [3] assert: Larger Text is on screen at 201,84",
31
+ "color": "ok"
32
+ },
33
+ {
34
+ "t": 1790339125528,
35
+ "text": "flow completed \u2014 4/4 steps in 4645ms",
36
+ "color": "fg"
37
+ },
38
+ {
39
+ "t": 1790339125878,
40
+ "text": "iPhone 17 Pro \u00b7 402x874pt \u00b7 screen f0509d55 (known)",
41
+ "color": "dim"
42
+ },
43
+ {
44
+ "t": 1790339125938,
45
+ "text": "content:",
46
+ "color": "fg"
47
+ },
48
+ {
49
+ "t": 1790339125998,
50
+ "text": "\u00a0\u00a0#1 button 38,84 Display & Text Size",
51
+ "color": "fg"
52
+ },
53
+ {
54
+ "t": 1790339126058,
55
+ "text": "\u00a0\u00a0#2 heading 201,84 Larger Text",
56
+ "color": "fg"
57
+ },
58
+ {
59
+ "t": 1790339126118,
60
+ "text": "\u00a0\u00a0#3 switch 337,161 Larger Accessibility Sizes = 0",
61
+ "color": "fg"
62
+ },
63
+ {
64
+ "t": 1790339126178,
65
+ "text": "\u00a0\u00a0#5 slider 200,783 DYNAMIC_TYPE_SLIDER = 50%",
66
+ "color": "fg"
67
+ },
68
+ {
69
+ "t": 1790339126238,
70
+ "text": "next: settled; nothing ambiguous \u2014 chain the next steps in one call",
71
+ "color": "dim"
72
+ }
73
+ ]
74
+ }
@@ -0,0 +1,6 @@
1
+ [
2
+ { "tap": "Accessibility" },
3
+ { "tap": "Display & Text Size" },
4
+ { "tap": "Larger Text" },
5
+ { "assert": { "value": "Larger Text" } }
6
+ ]
Binary file