diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index d82d0c4173..1e0b251fd5 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -8,7 +8,7 @@ Typing uses AC's `compkey` sample and QWERTY pitch mapping. Enter sends the prom ## Story cards -The stacked-cards button opens the selected version's ancestry as a vertical story. Pause, previous, next, and close leave the editing selection intact. The picture keeps its 4:3 aspect; its version and spoken caption sit directly below it with space reserved around them for social-app overlays. +The stacked-cards button opens the selected version's ancestry as a vertical story. Pause, previous, next, and close leave the editing selection intact. The picture keeps its 4:3 aspect; its version and spoken caption sit directly below it with space reserved around them for social-app overlays. Each version gets a stable colored card background behind both the picture and captions. Version labels use the same bold Comic lettering, dark outline, and cyan/pink shadows as the editing interface; MP4s retain that styling. Export uses the shared AC `canvas-tape.mjs` hardware encoder, also used by BIOS HD tapes. An internal 1080×1920 canvas composes only program pixels and captions; native AVFoundation adds the original local utterances or file-rendered speech. No screen or microphone recording is used. Export runs through the story in the foreground, with a ten-minute/256 MB limit, cancellation, and a local MP4 preview offering Save video (Photos add-only permission) and Share. Program-generated synth audio is not yet mixed into this story export. diff --git a/apple/whistlegraph/Resources/Web/story-tape.js b/apple/whistlegraph/Resources/Web/story-tape.js index 72916fdd9a..3441ef8f67 100644 --- a/apple/whistlegraph/Resources/Web/story-tape.js +++ b/apple/whistlegraph/Resources/Web/story-tape.js @@ -6,12 +6,18 @@ const canvas=document.createElement('canvas'); canvas.width=1080;canvas.height=1920; const ctx=canvas.getContext('2d'); function draw() { - ctx.fillStyle='black';ctx.fillRect(0,0,1080,1920);ctx.imageSmoothingEnabled=false; + ctx.fillStyle=state.background||'rgb(58,38,89)';ctx.fillRect(0,0,1080,1920);ctx.imageSmoothingEnabled=false; const layers=[document.querySelector('canvas[data-ac-visit-canvas]'), ...document.querySelectorAll('canvas[data-type="webgl-composite"],canvas[data-type="webgpu"],canvas[data-type="hd"]')]; let drew=false; for(const layer of layers) if(layer?.width && getComputedStyle(layer).display!=='none') { ctx.drawImage(layer,76,270,928,696);drew=true; } if(!drew)throw Error('The piece canvas is unavailable.'); - ctx.fillStyle='#aaa';ctx.font='40px monospace';ctx.fillText('v'+(state.version??''),76,1030); + const version='v'+(state.version??''); + ctx.font='700 58px WhistlegraphComicBold, sans-serif'; + ctx.fillStyle='#00ffff';ctx.fillText(version,82,1036); + ctx.fillStyle='#ff2d55';ctx.fillText(version,79,1033); + ctx.fillStyle='#141414'; + for(let i=0;i<8;i++)ctx.fillText(version,76+Math.cos(i*Math.PI/4)*2.7,1030+Math.sin(i*Math.PI/4)*2.7); + ctx.fillStyle='#fff5e8';ctx.fillText(version,76,1030); ctx.fillStyle='white';ctx.font='58px WhistlegraphComic, sans-serif'; const words=String(state.caption||'').split(/\s+/);let line='',y=1120; for(const word of words){const next=line?line+' '+word:word;if(ctx.measureText(next).width>928&&line){ctx.fillText(line,76,y);y+=72;line=word;if(y>1320)break;}else line=next;} diff --git a/apple/whistlegraph/Sources/StoryCardStyle.swift b/apple/whistlegraph/Sources/StoryCardStyle.swift new file mode 100644 index 0000000000..3d743810e0 --- /dev/null +++ b/apple/whistlegraph/Sources/StoryCardStyle.swift @@ -0,0 +1,16 @@ +import Foundation + +enum StoryCardStyle { + // Dark enough for white captions and the interface's cyan/pink lettering. + static let backgrounds = [ + [58, 38, 89], [19, 71, 77], [95, 44, 42], [31, 52, 103], + [53, 74, 38], [91, 33, 67], [22, 75, 57], [90, 57, 30] + ] + static func background(code: String, version: Int) -> [Int] { + let seed = code.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % backgrounds.count } + return backgrounds[(seed + max(0, version) % backgrounds.count) % backgrounds.count] + } + static func cssBackground(code: String, version: Int) -> String { + "rgb(" + background(code: code, version: version).map(String.init).joined(separator: ",") + ")" + } +} diff --git a/apple/whistlegraph/Sources/StoryExport.swift b/apple/whistlegraph/Sources/StoryExport.swift index 9a4cf93fbc..9d8fcc4890 100644 --- a/apple/whistlegraph/Sources/StoryExport.swift +++ b/apple/whistlegraph/Sources/StoryExport.swift @@ -53,7 +53,7 @@ struct StoryMovie: Identifiable { let id = UUID(); let url: URL } } #endif keys = Dictionary(uniqueKeysWithValues: rows.map { row in - (row.id, StoryCache.key(["story-card-v2-jeffrey", session.snapshot.code, String(row.id), row.createdAt, row.utterance, row.recordingID ?? "", String(session.pixelSize)])) + (row.id, StoryCache.key(["story-card-v3-comic", session.snapshot.code, String(row.id), row.createdAt, row.utterance, row.recordingID ?? "", String(session.pixelSize), StoryCardStyle.cssBackground(code: session.snapshot.code, version: row.id)])) }) storyKey = StoryCache.key(["story-movie-v2"] + rows.compactMap { keys[$0.id] }) readyURL = cache.find(storyKey); completedCards = rows.filter { cached($0) != nil }.count @@ -81,7 +81,7 @@ struct StoryMovie: Identifiable { let id = UUID(); let url: URL } FileManager.default.createFile(atPath: url.path, contents: nil) raw = url; file = try FileHandle(forWritingTo: url) session?.storyTapeEvent = { [weak self] event in self?.receive(event) } - try await session?.storyTape("update", arguments: ["value": ["version": row.id, "caption": row.utterance]]) + try await session?.storyTape("update", arguments: ["value": ["version": row.id, "caption": row.utterance, "background": StoryCardStyle.cssBackground(code: session?.snapshot.code ?? "", version: row.id)]]) guard operation == expected, active else { return } try await session?.storyTape("start", arguments: ["id": expected.uuidString]) guard operation == expected, active else { return } diff --git a/apple/whistlegraph/Sources/StoryTape.swift b/apple/whistlegraph/Sources/StoryTape.swift index ddff6d24db..c075e57d26 100644 --- a/apple/whistlegraph/Sources/StoryTape.swift +++ b/apple/whistlegraph/Sources/StoryTape.swift @@ -6,7 +6,8 @@ struct StoryTape { let encoder = try? String(contentsOf: root.appendingPathComponent("canvas-tape.mjs")), let story = try? String(contentsOf: root.appendingPathComponent("story-tape.js")) else { return "" } let font = (try? Data(contentsOf: root.appendingPathComponent("ComicRelief-Regular.ttf")))?.base64EncodedString() ?? "" - let loadFont = "window.whistlegraphStoryFont = new FontFace('WhistlegraphComic', 'url(data:font/ttf;base64,\(font))').load().then(font => document.fonts.add(font));\n" + let bold = (try? Data(contentsOf: root.appendingPathComponent("ComicRelief-Bold.ttf")))?.base64EncodedString() ?? "" + let loadFont = "window.whistlegraphStoryFont = Promise.all([new FontFace('WhistlegraphComic', 'url(data:font/ttf;base64,\(font))'), new FontFace('WhistlegraphComicBold', 'url(data:font/ttf;base64,\(bold))', {weight:'700'})].map(font => font.load().then(loaded => document.fonts.add(loaded))));\n" return loadFont + encoder.replacingOccurrences(of: "export function", with: "function") + "\n" + story } } diff --git a/apple/whistlegraph/Sources/WhistlegraphScreen.swift b/apple/whistlegraph/Sources/WhistlegraphScreen.swift index ca38bcfc72..7448abf559 100644 --- a/apple/whistlegraph/Sources/WhistlegraphScreen.swift +++ b/apple/whistlegraph/Sources/WhistlegraphScreen.swift @@ -80,6 +80,10 @@ struct WhistlegraphScreen: View { private var theme: WhistlegraphTheme { WhistlegraphTheme(phase: themePhase, dark: colorScheme == .dark) } private var paper: Color { theme.foreground } private var accent: Color { theme.accent } + private var storyBackground: Color { + let rgb = StoryCardStyle.background(code: session.snapshot.code, version: session.snapshot.head) + return Color(red: Double(rgb[0]) / 255, green: Double(rgb[1]) / 255, blue: Double(rgb[2]) / 255) + } private var chalkActive: Bool { drawing.enabled || held || session.capturePhase == .opening || session.capturePhase == .recording } var body: some View { VStack(spacing: narrator.isPlaying ? 0 : session.layout.spacing) { @@ -152,8 +156,7 @@ struct WhistlegraphScreen: View { } if narrator.isPlaying { VStack(alignment: .leading, spacing: 6) { - Text("v\(session.snapshot.head)") - .font(.system(size: 15, weight: .semibold, design: .monospaced)).foregroundStyle(.white.opacity(0.65)) + ComicTitle(text: "v\(session.snapshot.head)", size: 22) .accessibilityLabel("Running version \(session.snapshot.head)") .accessibilityIdentifier("story-version") PlaybackCaption(text: narrator.utterance, spokenRange: narrator.spokenRange, accent: .yellow) @@ -169,7 +172,7 @@ struct WhistlegraphScreen: View { .padding(.bottom, narrator.isPlaying ? 150 : 0) .frame(maxWidth: .infinity) .frame(height: narrator.isPlaying ? UIScreen.main.bounds.width * 16 / 9 : nil) - .background(narrator.isPlaying ? Color.black : Color.clear) + .background(narrator.isPlaying ? storyBackground : Color.clear) if !narrator.error.isEmpty && !narrator.isPlaying { Text(narrator.error).foregroundStyle(.orange) } if let failure = session.captureError, !narrator.isPlaying { Text(failure).font(.body).foregroundStyle(.orange).frame(maxWidth: .infinity, alignment: .leading) } if !narrator.isPlaying && (session.snapshot.hasPiece || session.snapshot.hasHistory || session.snapshot.busy || session.snapshot.attempt?.status == "failed") { @@ -197,7 +200,7 @@ struct WhistlegraphScreen: View { } } }.padding(.horizontal, session.layout.pageInset).foregroundStyle(paper) - .background(narrator.isPlaying ? Color.black : theme.background) + .background(narrator.isPlaying ? storyBackground : theme.background) } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { @@ -219,7 +222,7 @@ struct WhistlegraphScreen: View { .onChange(of: scenePhase) { _, value in if value == .background { exporter.cancel(); narrator.stop() } else if value == .inactive { narrator.setPaused(true) } } .onChange(of: session.capturePhase) { _, value in if value != .idle { narrator.stop() } } .onDisappear { exporter.cancel(); narrator.stop() } - .background((narrator.isPlaying ? Color.black : theme.background).ignoresSafeArea()) + .background((narrator.isPlaying ? storyBackground : theme.background).ignoresSafeArea()) .tint(paper) .animation(reduceMotion ? nil : .easeInOut(duration: 0.3), value: themePhase) } diff --git a/apple/whistlegraph/Tests/build97-checks.json b/apple/whistlegraph/Tests/build97-checks.json new file mode 100644 index 0000000000..453f624921 --- /dev/null +++ b/apple/whistlegraph/Tests/build97-checks.json @@ -0,0 +1,13 @@ +{ + "build": 97, + "date": "2026-10-03", + "deviceBuild": "Signed iOS build passed and installed on paired iPhone.", + "renderer": "All eight backgrounds rendered with the actual story-tape compositor and bundled regular/bold Comic fonts. Contact sheet and full-size card visually inspected; no browser client errors.", + "screenshots": [ + "Tests/screenshots/build97-colors.png", + "Tests/screenshots/build97-comic-version.png" + ], + "regression": "Three story-tape lifecycle checks passed.", + "cache": "Card cache format bumped to v3-comic; existing black-background exports are not reused. Repeated native composition check cancelled under host load; earlier build96 checks remain recorded separately.", + "nativeUI": "Not exercised on the physical phone for this change." +} diff --git a/apple/whistlegraph/Tests/story-cache.test.sh b/apple/whistlegraph/Tests/story-cache.test.sh index 96d708ebf6..b630472313 100644 --- a/apple/whistlegraph/Tests/story-cache.test.sh +++ b/apple/whistlegraph/Tests/story-cache.test.sh @@ -4,5 +4,5 @@ app_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cache_check_dir=$(mktemp -d "${TMPDIR:-/tmp}/whistlegraph-cache.XXXXXX") trap 'rm -rf "$cache_check_dir"' EXIT HUP INT TERM ffmpeg -v error -f lavfi -i color=c=pink:s=320x240:r=30 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 0.25 -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart "$cache_check_dir/card.mp4" -swiftc -parse-as-library "$app_dir/Sources/StoryCache.swift" "$app_dir/Sources/StoryExport.swift" "$app_dir/Tests/StoryCacheCheck.swift" -o "$cache_check_dir/check" 2>"$cache_check_dir/compile.log" || { cat "$cache_check_dir/compile.log"; exit 1; } +swiftc -parse-as-library "$app_dir/Sources/StoryCache.swift" "$app_dir/Sources/StoryCardStyle.swift" "$app_dir/Sources/StoryExport.swift" "$app_dir/Tests/StoryCacheCheck.swift" -o "$cache_check_dir/check" 2>"$cache_check_dir/compile.log" || { cat "$cache_check_dir/compile.log"; exit 1; } "$cache_check_dir/check" "$cache_check_dir/card.mp4" diff --git a/apple/whistlegraph/UITests/StoryCardsTests.swift b/apple/whistlegraph/UITests/StoryCardsTests.swift index 76f9811003..ab0309bff0 100644 --- a/apple/whistlegraph/UITests/StoryCardsTests.swift +++ b/apple/whistlegraph/UITests/StoryCardsTests.swift @@ -88,14 +88,14 @@ final class StoryCardsTests: XCTestCase { XCTAssertTrue(pause.waitForExistence(timeout: 10)); pause.tap() XCTAssertEqual(pause.label, "Resume story") XCTAssertTrue(app.buttons["story-export"].exists) - XCTAssertTrue(app.staticTexts["story-version"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "story-version").firstMatch.waitForExistence(timeout: 10)) let caption = app.staticTexts["spoken-word"] XCTAssertTrue(caption.exists) let picture = app.webViews["story-picture"] - if picture.exists { XCTAssertGreaterThanOrEqual(app.staticTexts["story-version"].frame.minY, picture.frame.maxY) } + if picture.exists { XCTAssertGreaterThanOrEqual(app.descendants(matching: .any).matching(identifier: "story-version").firstMatch.frame.minY, picture.frame.maxY) } XCTAssertLessThan(caption.frame.maxY, app.frame.height * 0.8) app.buttons["Next card"].tap() - let version = app.staticTexts["story-version"] + let version = app.descendants(matching: .any).matching(identifier: "story-version").firstMatch expectation(for: NSPredicate(format: "label == %@", "Running version 3"), evaluatedWith: version) waitForExpectations(timeout: 10) // v2 is a sibling branch and must not appear in this story. diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index ececc01e20..2313d79ca9 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 49D6E6ED3128002039D4F931 /* UtteranceRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9B92C3457F07429C8C2600 /* UtteranceRecording.swift */; }; 4B35B066F49BDD9BD8B90B5A /* WhistlegraphScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66B3ADC4D6D837FABC53B8F /* WhistlegraphScreen.swift */; }; 5646BD41AA620CC47BF89361 /* Web in Resources */ = {isa = PBXBuildFile; fileRef = 4BA75145769FC4401A11BBE8 /* Web */; }; + 603B85FC4E28D17FF1E5F457 /* StoryCardStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477B640525BC627AFB40D7A7 /* StoryCardStyle.swift */; }; 73873BDDC483417E2F6E1143 /* WhistlegraphHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BC2C1DC5E036F689EA59561 /* WhistlegraphHeader.swift */; }; 749AC25AE02AAE4BCDA5683A /* VisualCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */; }; 77291EEE768914D5F5EDB84B /* PlaybackWord.swift in Sources */ = {isa = PBXBuildFile; fileRef = E97104E13862EE30444D07C9 /* PlaybackWord.swift */; }; @@ -69,6 +70,7 @@ 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputButtonLabels.swift; sourceTree = ""; }; 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSheetsTests.swift; sourceTree = ""; }; 4728CB634CB3E4AED4F4D353 /* TalkHoldInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TalkHoldInput.swift; sourceTree = ""; }; + 477B640525BC627AFB40D7A7 /* StoryCardStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCardStyle.swift; sourceTree = ""; }; 49854384EFBA11ABDE4F70C6 /* StoryVoice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryVoice.swift; sourceTree = ""; }; 4BA75145769FC4401A11BBE8 /* Web */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Web; path = Resources/Web; sourceTree = SOURCE_ROOT; }; 4BBA0E45689010CB41A6A8FF /* NetworkBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkBenchmark.swift; sourceTree = ""; }; @@ -137,6 +139,7 @@ B55FB4AA387E716F9FEE0F83 /* SequenceBenchmark.swift */, B7A25F803FAC121BFECC1AD7 /* SpeechCapture.swift */, E86622F951A1F67F055D69F9 /* StoryCache.swift */, + 477B640525BC627AFB40D7A7 /* StoryCardStyle.swift */, 8C42750EF4F7E96146B03585 /* StoryControls.swift */, 88B494AD8CD86FCC5DE0E4CC /* StoryExport.swift */, 250E0B1999C7810299ACB7A9 /* StoryMovieSheet.swift */, @@ -297,6 +300,7 @@ C4390906B03E8E8666BC54A3 /* SequenceBenchmark.swift in Sources */, D3D16A0333764D0E5C3B620D /* SpeechCapture.swift in Sources */, 7C8A0DCC5322399983CC8E43 /* StoryCache.swift in Sources */, + 603B85FC4E28D17FF1E5F457 /* StoryCardStyle.swift in Sources */, 3CF6178F7BA1702ADFA457B7 /* StoryControls.swift in Sources */, 47DADA794ABD0CD203CFF48C /* StoryExport.swift in Sources */, 03C5B25FC742430AFD02AD1E /* StoryMovieSheet.swift in Sources */, @@ -424,7 +428,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 96; + CURRENT_PROJECT_VERSION = 97; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -509,7 +513,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 96; + CURRENT_PROJECT_VERSION = 97; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index e6da96cf86..c61b40e9a7 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -9,7 +9,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "96" + CURRENT_PROJECT_VERSION: "97" targets: Whistlegraph: type: application