diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index 265801767d..d82d0c4173 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -41,7 +41,7 @@ The browser check uses Puppeteer and Chrome with mock inference; it makes no mod Every generated edit runs syntax/API checks and waits for matching-source runtime feedback before the visual review. Client errors trigger at most one repair across code and visual checks. A candidate that still fails restores the previous saved version; its failure remains visible after the restored frame paints. Missing or stale evidence cannot trigger a paid repair or save a version. There is no opt-out setting. -The initial generation allows four tool rounds and one output continuation, with two repair rounds and no repair continuation. Provider calls use a 4,096-token output limit. Code generation/checking has a 75-second deadline; visual review has a separate 90-second deadline. These bound work, not cost. A painted frame is execution evidence, not visual acceptance. +The initial generation uses DeepSeek V4.1 Flash with four tool rounds and one output continuation. The single repair uses DeepSeek V4 Pro with a 1,024-token thinking budget, two tool rounds and no continuation. Provider calls retain the 4,096-token output limit. Remote requests and remote edit descriptions use the same 96-grapheme, single-line limit as phone typing; oversized requests are rejected before generation. Diagnostic context and source code are separate from the short user request. Code generation/checking has a 75-second deadline; visual review has a separate 90-second deadline. These bound work, not cost. A painted frame is execution evidence, not visual acceptance. ## Pixel size @@ -49,7 +49,13 @@ Tap the piece name → Pixel size: **1×, 2×, 3×, 4×**. The default is **2×* ## Story export -The story share button shows preparation, card-rendering progress, audio preparation, and the encoder's MP4 percentage. Cancel stops the export and returns to the story. The completed video opens with Save video and Share; saving to Photos displays activity until it finishes. Video capture runs at the story's playback speed. +Opening cards starts preparing the MP4 as they play. Each completed card is cached on the device; pause omits paused time and seeking discards only an unfinished card. The selected branch is assembled from those clips without re-encoding their video. Reopening a completed story can share immediately, including after relaunch. Cache keys include piece identity, immutable revision metadata, pixel size, and rendering/voice format. Changed branches reuse unchanged cards. The movie cache evicts older unprotected files above 256 MB; iOS may also reclaim it. + +The share button shows progress when preparation is unfinished and opens a compact Save video / Share MP4 popover when ready. Automatic preparation never opens the popover. Save video shows activity through the Photos write. Capture runs at playback speed while cards remain open in the foreground; leaving cards or backgrounding the app stops unfinished work and retains completed clips. + +Original voice recordings remain the narration when available. Computer narration uses Jeffrey’s ElevenLabs voice through AC `/api/say`, shared by card playback and export, with a 32 MB local audio cache. The API key stays on the server. If the service is unavailable, device speech can finish the export; fallback clips have separate cache keys so reopening can retry Jeffrey’s voice. + +`Tests/StoryCacheCheck.swift` exercises real AVFoundation composition, partial completion/relaunch, warm export, density invalidation, cancellation, and cache eviction with a supplied MP4 fixture; run `sh apple/whistlegraph/Tests/story-cache.test.sh` on macOS to generate that fixture and run the check. `Tests/story-tape.test.mjs` covers pause/resume and stale chunks after cancellation. `StoryCardsTests` covers background preparation and the compact cached-export popover. ## Attempt receipts diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index 8e86d9d798..4c4edfbaf3 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -1,4 +1,5 @@ import {withDrawing,inputData,drawingImage,drawingContent} from './drawing-input.mjs'; +import {checkedPrompt} from './prompt-limit.mjs'; import {reviewVisualResult,reviewWithRepair} from './visual-review.mjs'; import {inferenceRequest,wantsSoundEvidence} from './inference-input.mjs'; import {contextualRequest,selectedBranch} from './branch-context.mjs'; @@ -18,7 +19,7 @@ import {musicalPrompt} from './musical-input.mjs'; import {PieceVersions} from './piece-versions.mjs'; // The same inference/tool loop as Aesel, with a piece-first streaming renderer. import {AcServer} from '/easel/src/ac-server.mjs'; -import {DEFAULT_MODEL,GENERATION_INSTRUCTIONS} from './generation-policy.mjs'; +import {DEFAULT_MODEL,REPAIR_MODEL,GENERATION_INSTRUCTIONS} from './generation-policy.mjs'; import {runnablePrefix} from './stream-preview.mjs'; import * as vfs from '/easel/phone/shim/fs.mjs'; @@ -284,13 +285,13 @@ vfs.setWriteHandler((path,value)=>{ }); const guides = vfs.preload(['pieces.md','screen.md','hand.md','kidlisp.md','api.json'].map(name=>'/easel/context/'+name)); function makeServer({repair=false}={}){ - const value=new AcServer({cwd:'/piece',piece:{file,checkpoint:async()=>{const target=source;for(let i=0;i<100;i++){if(painted&&lastPaintedSource===target)return;await new Promise(resolve=>setTimeout(resolve,20));}}},frameCapture:false,layeredEdits:true,token:()=>token,model:window.__walkiewareModel||DEFAULT_MODEL, + const value=new AcServer({cwd:'/piece',piece:{file,checkpoint:async()=>{const target=source;for(let i=0;i<100;i++){if(painted&&lastPaintedSource===target)return;await new Promise(resolve=>setTimeout(resolve,20));}}},frameCapture:false,layeredEdits:true,token:()=>token,model:repair?(window.__walkiewareRepairModel||REPAIR_MODEL):(window.__walkiewareModel||DEFAULT_MODEL), fetch:async(url,options)=>{ benchmark('requestDispatched');const recorder=activeReceipt,round=recorder?.request(); const body=JSON.parse(options.body);body.max_tokens=4096;options={...options,body:JSON.stringify(body)}; const response=await globalThis.fetch(url,options);if(round)recorder.headers(round,response); benchmark('inferenceHeaders',{status:response.status});return response; - },preview:true,rounds:repair?2:4,outputContinuations:repair?0:1,reasoning:{effort:'none'},thinking:{type:'disabled'}, + },preview:true,rounds:repair?2:4,outputContinuations:repair?0:1,reasoning:repair?{max_tokens:1024}:{effort:'none'},thinking:repair?{type:'enabled',budget_tokens:1024}:{type:'disabled'}, developerInstructions:GENERATION_INSTRUCTIONS}); value.runtimeFeedback=()=>feedback; value.on('notification',({method,params})=>{ @@ -520,16 +521,19 @@ if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!windo } await verifyThreadRevision(command,()=>({busy,head:versions.head.id,source:versions.head.source})); const before=versions.head.id; - if(command.action==='ask')await ask(command.text); + if(command.action==='ask')await ask(checkedPrompt(command.text)); else if(command.action==='undo')window.walkiewareUndo(); else if(command.action==='edit') { + const request=checkedPrompt(command.text||'Remote source edit'); + const findings=sourceChecks(command.source); + if(findings.length)throw Error('Source check failed: '+findings.map(f=>f.message).join('; ')); busy=true;previous=source;turnRuntimeFailed=false;turnCancelled=false;checkpoints=1;server?.close();server=null;review(false); source=command.source;vfs.mount(file,source);phase('Applying remote edit…');render(source); try { for(let i=0;i<500&&!painted&&!turnRuntimeFailed&&!turnCancelled;i++)await new Promise(r=>setTimeout(r,10)); await new Promise(r=>setTimeout(r,250)); if(!painted||lastPaintedSource!==source||turnRuntimeFailed||turnCancelled)throw Error('Remote edit did not paint cleanly'); - versions.commit({source,request:'Remote source edit',layers:1,parent:before});saved();phase('Ready to play'); + versions.commit({source,request,layers:1,parent:before});saved();phase('Ready to play'); }catch(error){source=previous;vfs.mount(file,source);render(source);throw error;} finally{end();} } diff --git a/apple/whistlegraph/Resources/Web/generation-policy.mjs b/apple/whistlegraph/Resources/Web/generation-policy.mjs index 96757dd811..27087baf3c 100644 --- a/apple/whistlegraph/Resources/Web/generation-policy.mjs +++ b/apple/whistlegraph/Resources/Web/generation-policy.mjs @@ -1,2 +1,3 @@ export const DEFAULT_MODEL = 'deepseek/deepseek-v4.1-flash'; +export const REPAIR_MODEL = 'deepseek/deepseek-v4-pro'; export const GENERATION_INSTRUCTIONS = 'This surface is Whistlegraph. The person watches the piece being made. A local starter may already be visible: its source is the real current piece; refine it instead of discarding it. For an existing piece, make small revision-checked edit_piece calls instead of resending unchanged code. Use write_piece to create the initial piece or for a necessary rewrite. No prose preamble. Each ask becomes one internal version; complete edit checkpoints are preview layers within it. Make the first visible working drawing in 3–6 lines whenever possible. The UI compiles runnable module prefixes during streaming: put required declarations and a complete exported paint function first; avoid long data tables or helper code before that first drawing. Then refine in small complete write_piece checkpoints only as needed to satisfy the request. Do not add unrequested animation or features. For animation use Number(paintCount) from the paint API, or explicit state advanced in sim; there is no numeric frame API. A request for a spoken narrator needs audible speak(), not just written notes; use the speech guide. Use numeric RGB ink(r,g,b) or six-digit hex colors like "#ffffff"; three-digit CSS hex colors are unsupported. Use AC ink alpha on the 0–255 scale and size geometry relative to the screen. Every saved piece must have working helpers and a caption; never finish with placeholder or empty drawing helpers. line(x1,y1,x2,y2) draws ONE segment; its fifth argument is thickness, never another coordinate. For a path, loop over adjacent point pairs with separate four-argument line calls; never spread three or more point pairs into line. Use the native oval(x,y,radiusX,radiusY,filled) for ellipses instead of approximating them with overlapping circles. Fit the entire composition, including radii and stroke thickness, inside the screen with margins; use at most 90% of the fitting scale and center it. Do not repeat the prompt or add interaction instructions, help text, usage hints, or captions inside or outside the picture unless explicitly requested. Use the default write font for other in-piece text unless the person requests another. Use ac_preview for runtime observations; this phone has no desktop frame-capture service. Once the requested change is painted and runtime-checked, finish the generation turn. The app then captures four timed frames of the actual phone preview and reviews them against the latest request and selected branch before saving. A failed visual check can return concrete mismatches for one narrow repair; do not claim visual success yourself. Never fake progress or tests. Keep one line `export const caption = "…"` in the piece: a plain sentence under 120 characters naming the subject, its mood, what actually moves and what the person can actually do, if anything; never invent motion or interactions in the caption. Update it whenever an edit changes any of that. It is the piece\'s memory of itself and comes back to you with every follow-up. Preserve the current piece and the original intent of the selected branch on revisions. Short follow-ups modify that existing world; do not replace its subject, characters, message, interactions, or sound without a request to do so. Treat uncertain speech transcriptions conservatively in context instead of inventing a new subject from an ambiguous word. Adjectives of culture, nationality or place (Latina, Japanese, Nordic, tropical) describe how things look: palette, patterns, dress, flora, light. They never change the language of any text or speech unless the request mentions words, text, language or voice. Do not turn ordinary spoken requests into text posters, transcript timelines, waveform charts, or word-timed animation unless explicitly requested. No publishing: these are private local previews.'; diff --git a/apple/whistlegraph/Resources/Web/prompt-limit.mjs b/apple/whistlegraph/Resources/Web/prompt-limit.mjs new file mode 100644 index 0000000000..3db41954fb --- /dev/null +++ b/apple/whistlegraph/Resources/Web/prompt-limit.mjs @@ -0,0 +1,8 @@ +export const PROMPT_LIMIT = 96; +const characters = text => [...new Intl.Segmenter(undefined, {granularity:'grapheme'}).segment(text)]; +export function checkedPrompt(text) { + if (typeof text !== 'string' || !text.trim()) throw Error('A request is required'); + const prompt = text.replace(/[\r\n\u2028\u2029]+/g, ' ').trim(); + if (characters(prompt).length > PROMPT_LIMIT) throw Error(`Requests must be ${PROMPT_LIMIT} characters or fewer`); + return prompt; +} diff --git a/apple/whistlegraph/Resources/Web/story-tape.js b/apple/whistlegraph/Resources/Web/story-tape.js index 8297201925..72916fdd9a 100644 --- a/apple/whistlegraph/Resources/Web/story-tape.js +++ b/apple/whistlegraph/Resources/Web/story-tape.js @@ -1,8 +1,8 @@ // Injected into the piece frame with the shared AC canvas-tape encoder. (() => { if (window === window.top || location.origin !== 'https://aesthetic.computer') return; - let recorder, timer, session, state={}, delivery=Promise.resolve(), failure=null; - const post = body => window.webkit.messageHandlers.walkie.postMessage({action:'storyTape',session,...body}); + let capture=null, state={}; + const post = (session,body) => window.webkit.messageHandlers.walkie.postMessage({action:'storyTape',session,...body}); const canvas=document.createElement('canvas'); canvas.width=1080;canvas.height=1920; const ctx=canvas.getContext('2d'); function draw() { @@ -20,19 +20,29 @@ window.whistlegraphStoryTape={ update(value){state=value;}, async start(id){ - await window.whistlegraphStoryFont; - if(recorder)throw Error('A tape is already exporting.');session=id;failure=null;delivery=Promise.resolve(); - draw();recorder=createCanvasTapeRecorder(canvas,{fps:30,mp4Only:true}); - recorder.ondataavailable=e=>{if(!e.data.size)return;delivery=delivery.then(async()=>{ - const bytes=new Uint8Array(await e.data.arrayBuffer()); - for(let i=0;i{failure=error;});}; - recorder.onerror=e=>{failure=e.error||Error('Canvas tape encoding failed');}; - recorder.onstop=()=>{clearInterval(timer);delivery.then(()=>post(failure?{kind:'error',error:failure.message}:{kind:'done'}));recorder=null;}; - timer=setInterval(()=>{try{draw();}catch(error){failure=error;clearInterval(timer);if(recorder?.state!=='inactive')recorder.stop();}},1000/30); - recorder.start(1000); + if(capture)throw Error('A tape is already exporting.'); + const current={id,recorder:null,timer:null,delivery:Promise.resolve(),failure:null};capture=current; + try { + await window.whistlegraphStoryFont; + if(capture!==current)return; + draw();const recorder=current.recorder=createCanvasTapeRecorder(canvas,{fps:30,mp4Only:true}); + recorder.ondataavailable=e=>{if(!e.data.size)return;current.delivery=current.delivery.then(async()=>{ + if(capture!==current)return; + const bytes=new Uint8Array(await e.data.arrayBuffer()); + for(let i=0;i{current.failure=error;});}; + recorder.onerror=e=>{current.failure=e.error||Error('Canvas tape encoding failed');}; + recorder.onstop=()=>{clearInterval(current.timer);current.delivery.then(()=>{ + if(capture!==current)return;capture=null; + post(id,current.failure?{kind:'error',error:current.failure.message}:{kind:'done'}); + });}; + current.timer=setInterval(()=>{try{draw();}catch(error){current.failure=error;clearInterval(current.timer);if(recorder.state!=='inactive')recorder.stop();}},1000/30); + recorder.start(1000); + } catch(error) { if(capture===current){window.whistlegraphStoryTape.cancel();throw error;} } }, - stop(){if(recorder?.state!=='inactive')recorder?.stop();}, - cancel(){clearInterval(timer);if(recorder){recorder.ondataavailable=null;recorder.onstop=null;if(recorder.state!=='inactive')recorder.stop();recorder=null;}session=null;} + pause(){if(capture?.recorder?.state==='recording')capture.recorder.pause();}, + resume(){if(capture?.recorder?.state==='paused')capture.recorder.resume();}, + stop(){if(capture?.recorder?.state!=='inactive')capture?.recorder?.stop();}, + cancel(){const current=capture;capture=null;if(!current)return;clearInterval(current.timer);const recorder=current.recorder;if(recorder){recorder.ondataavailable=null;recorder.onstop=null;if(recorder.state!=='inactive')recorder.stop();}} }; })(); diff --git a/apple/whistlegraph/Sources/NativeScreenFixture.swift b/apple/whistlegraph/Sources/NativeScreenFixture.swift index 8c6f372c35..b8687c334b 100644 --- a/apple/whistlegraph/Sources/NativeScreenFixture.swift +++ b/apple/whistlegraph/Sources/NativeScreenFixture.swift @@ -19,10 +19,10 @@ enum NativeScreenFixture { let input = ["transcript": "OK go like…", "sound": sound] as [String: Any] let request = "Sound request\nINPUT DATA:\n" + String(data: try! JSONSerialization.data(withJSONObject: input), encoding: .utf8)! let versions: [[String: Any]] = [ - ["id": 0, "parent": NSNull(), "source": "", "request": NSNull(), "createdAt": formatter.string(from: Date().addingTimeInterval(-420))], - ["id": 1, "parent": 0, "source": source, "request": request, "createdAt": formatter.string(from: Date().addingTimeInterval(-180))], - ["id": 2, "parent": 1, "source": source, "request": "Make it pink", "createdAt": formatter.string(from: Date().addingTimeInterval(-120))], - ["id": 3, "parent": 1, "source": source, "request": "Add a little moon", "createdAt": formatter.string(from: Date().addingTimeInterval(-30))] + ["id": 0, "parent": NSNull(), "source": "", "request": NSNull(), "createdAt": formatter.string(from: Date(timeIntervalSince1970: 1_780_000_000).addingTimeInterval(-420))], + ["id": 1, "parent": 0, "source": source, "request": request, "createdAt": formatter.string(from: Date(timeIntervalSince1970: 1_780_000_000).addingTimeInterval(-180))], + ["id": 2, "parent": 1, "source": source, "request": "Make it pink", "createdAt": formatter.string(from: Date(timeIntervalSince1970: 1_780_000_000).addingTimeInterval(-120))], + ["id": 3, "parent": 1, "source": source, "request": "Add a little moon", "createdAt": formatter.string(from: Date(timeIntervalSince1970: 1_780_000_000).addingTimeInterval(-30))] ] let ledger: [String: Any] = ["format": 1, "head": 3, "versions": versions] let json = String(data: try! JSONSerialization.data(withJSONObject: ledger), encoding: .utf8)! diff --git a/apple/whistlegraph/Sources/StoryCache.swift b/apple/whistlegraph/Sources/StoryCache.swift new file mode 100644 index 0000000000..175c851fb4 --- /dev/null +++ b/apple/whistlegraph/Sources/StoryCache.swift @@ -0,0 +1,45 @@ +import Foundation +import CryptoKit + +// Completed clips survive leaving cards and app launches. Incomplete files never +// become cache hits. The format version also invalidates old rendering/voice rules. +struct StoryCache { + let directory: URL + let limit: Int + init(name: String = "StoryMovies", limit: Int = 256_000_000, root: URL? = nil) { + directory = (root ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]).appendingPathComponent(name) + self.limit = limit + } + static func key(_ fields: [String]) -> String { + let bytes = try! JSONEncoder().encode(fields) + return SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + } + func url(_ key: String, ext: String = "mp4") -> URL { directory.appendingPathComponent(key).appendingPathExtension(ext) } + func find(_ key: String, ext: String = "mp4") -> URL? { + let path = url(key, ext: ext) + guard let size = try? path.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 0 else { return nil } + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: path.path) + return path + } + func store(_ source: URL, key: String, ext: String = "mp4", protecting: Set = []) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = url(key, ext: ext), staging = directory.appendingPathComponent(UUID().uuidString + ".partial") + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.copyItem(at: source, to: staging) + if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } + try FileManager.default.moveItem(at: staging, to: destination) + prune(protecting: protecting.union([key])) + return destination + } + func prune(protecting: Set = []) { + let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey])) ?? [] + let entries = files.compactMap { file -> (URL, Int, Date)? in + guard file.pathExtension != "partial", let values = try? file.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) else { return nil } + return (file, values.fileSize ?? 0, values.contentModificationDate ?? .distantPast) + }.sorted { $0.2 < $1.2 } + var size = entries.reduce(0) { $0 + $1.1 } + for (file, bytes, _) in entries where size > limit && !protecting.contains(file.deletingPathExtension().lastPathComponent) { + if (try? FileManager.default.removeItem(at: file)) != nil { size -= bytes } + } + } +} diff --git a/apple/whistlegraph/Sources/StoryControls.swift b/apple/whistlegraph/Sources/StoryControls.swift index d806767402..6858a08c1c 100644 --- a/apple/whistlegraph/Sources/StoryControls.swift +++ b/apple/whistlegraph/Sources/StoryControls.swift @@ -64,17 +64,24 @@ struct StoryControls: View { Button(action: export) { if exporter.busy { ProgressView().tint(.white).frame(width: 44, height: 44) } else { Image(systemName: "square.and.arrow.up").frame(width: 44, height: 44) } - }.accessibilityLabel("Export MP4").accessibilityIdentifier("story-export").disabled(exporter.busy) + }.accessibilityLabel("Export MP4").accessibilityIdentifier("story-export").disabled(exporter.requested) + .popover(item: $exporter.movie, arrowEdge: .top) { movie in + StoryMovieSheet(url: movie.url).presentationCompactAdaptation(.popover) + } + } + if exporter.readyURL != nil || exporter.busy { + Text(exporter.readyURL != nil ? "Video ready" : "Preparing video · \(exporter.completedCards)/\(narrator.count)") + .font(.caption).accessibilityIdentifier("story-video-status") } Spacer() HStack(spacing: 32) { Button { narrator.previous() } label: { Image(systemName: "backward.end.fill").frame(width: 52, height: 48) } - .accessibilityLabel("Previous card").disabled(exporter.busy) + .accessibilityLabel("Previous card").disabled(exporter.requested) Button { narrator.setPaused(!narrator.isPaused) } label: { Image(systemName: narrator.isPaused ? "play.fill" : "pause.fill").frame(width: 52, height: 48) - }.accessibilityLabel(narrator.isPaused ? "Resume story" : "Pause story").accessibilityIdentifier("story-pause").disabled(exporter.busy) + }.accessibilityLabel(narrator.isPaused ? "Resume story" : "Pause story").accessibilityIdentifier("story-pause").disabled(exporter.requested) Button { narrator.next() } label: { Image(systemName: "forward.end.fill").frame(width: 52, height: 48) } - .accessibilityLabel("Next card").disabled(exporter.busy) + .accessibilityLabel("Next card").disabled(exporter.requested) } }.font(.system(size: 20, weight: .semibold)).foregroundStyle(.white).buttonStyle(.plain) .padding(.horizontal, 20).padding(.vertical, 8) diff --git a/apple/whistlegraph/Sources/StoryExport.swift b/apple/whistlegraph/Sources/StoryExport.swift index 9f72f27ea9..9a4cf93fbc 100644 --- a/apple/whistlegraph/Sources/StoryExport.swift +++ b/apple/whistlegraph/Sources/StoryExport.swift @@ -3,101 +3,173 @@ import AVFoundation struct StoryMovie: Identifiable { let id = UUID(); let url: URL } +// One canvas encoder, one card at a time. Completed cards are durable; seeking +// discards only the unfinished card. Assembly reuses the cached MP4 tracks. @MainActor final class StoryExport: ObservableObject { enum Stage: Equatable { case preparing, rendering, finishingVideo, narration, encoding } @Published private(set) var busy = false + @Published private(set) var requested = false @Published private(set) var recording = false @Published private(set) var stage = Stage.preparing @Published private(set) var progress = 0.0 + @Published private(set) var completedCards = 0 + @Published private(set) var readyURL: URL? @Published var movie: StoryMovie? @Published var error = "" + private let cache: StoryCache + private static var fixtureReset = false private weak var session: WhistlegraphSession? + private var rows: [PieceRevision] = [] + private var keys: [Int: String] = [:] + private var storyKey = "" + private var active = false private var operation = UUID() + private var storyRun = UUID() + private var currentKey: String? private var raw: URL? - private var sharedURL: URL? private var file: FileHandle? private var bytes = 0 - private var tapeStarted = false + private var audio: StoryAudio? private var previousIdleTimer: Bool? - private var started = Date() private var exportSession: AVAssetExportSession? - private var narration: [(at: Double, task: Task<(URL, Double, Bool), Error>)] = [] private var deadline: Task? + private var completion: CheckedContinuation? + private var reset: Task? + private var assembly: Task? - func begin(session: WhistlegraphSession, started: @escaping () -> Void) { - guard !busy else { return } - self.session = session; operation = UUID(); let expected = operation - stage = .preparing; progress = 0 - busy = true; error = ""; bytes = 0; narration = []; tapeStarted = false - previousIdleTimer = UIApplication.shared.isIdleTimerDisabled; UIApplication.shared.isIdleTimerDisabled = true - Task { - do { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("story-canvas-\(expected).mp4") - FileManager.default.createFile(atPath: url.path, contents: nil) - raw = url; file = try FileHandle(forWritingTo: url) - session.storyTapeEvent = { [weak self] event in self?.receive(event) } - guard operation == expected else { return } - self.started = Date(); recording = true; started() - deadline = Task { [weak self] in - try? await Task.sleep(for: .seconds(600)) - guard !Task.isCancelled, let self, self.operation == expected else { return } - self.fail("Story export exceeded ten minutes. Export a shorter branch.") - } - } catch { if operation == expected { fail(error.localizedDescription) } } - } + init(cache: StoryCache? = nil) { + #if DEBUG + self.cache = cache ?? StoryCache(name: NativeScreenFixture.enabled ? "StoryMovies-Fixture" : "StoryMovies") + #else + self.cache = cache ?? StoryCache() + #endif } - func update(version: Int, caption: String) { - guard busy else { return } - Task { try? await session?.storyTape("update", arguments: ["value": ["version": version, "caption": caption]]) } + + func prepare(session: WhistlegraphSession, rows: [PieceRevision]) { + cancel(); self.session = session; self.rows = rows + #if DEBUG + if NativeScreenFixture.enabled, !Self.fixtureReset, ProcessInfo.processInfo.environment["WALKIE_RESET_STORY_CACHE"] == "1" { + try? FileManager.default.removeItem(at: cache.directory); Self.fixtureReset = true + } + #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)])) + }) + storyKey = StoryCache.key(["story-movie-v2"] + rows.compactMap { keys[$0.id] }) + readyURL = cache.find(storyKey); completedCards = rows.filter { cached($0) != nil }.count + active = true; busy = readyURL == nil; stage = .preparing; error = "" + if readyURL == nil { assembleIfReady() } } - func addNarration(_ row: PieceRevision) async { - guard recording else { return } + private func cached(_ row: PieceRevision) -> URL? { keys[row.id].flatMap { cache.find($0) } } + var firstMissingIndex: Int? { rows.firstIndex { cached($0) == nil } } + func needsCard(at index: Int) -> Bool { rows.indices.contains(index) && cached(rows[index]) == nil } + + // Called after this exact revision paints and its narration file is ready. + func startCard(_ row: PieceRevision, audio: StoryAudio?) async { + guard active, readyURL == nil, cached(row) == nil, assembly == nil else { return } + if let audio, audio.url.lastPathComponent.hasPrefix("story-voice-"), let key = keys[row.id] { + keys[row.id] = StoryCache.key([key, "device-fallback"]) + storyKey = StoryCache.key(["story-movie-v2"] + rows.compactMap { keys[$0.id] }) + } + discardCard() + let expected = operation + await reset?.value + guard active, operation == expected else { return } do { + currentKey = keys[row.id]; self.audio = audio; bytes = 0 + let url = FileManager.default.temporaryDirectory.appendingPathComponent("story-canvas-\(expected).mp4") + 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]]) - if !tapeStarted { - try await session?.storyTape("start", arguments: ["id": operation.uuidString]) - tapeStarted = true; started = Date(); stage = .rendering - } - } catch { fail(error.localizedDescription); return } - guard !row.utterance.isEmpty || row.recordingID != nil else { return } - let at = Date().timeIntervalSince(started) - let task = Task<(URL, Double, Bool), Error> { - if let id = row.recordingID, let url = UtteranceRecording.url(id), FileManager.default.fileExists(atPath: url.path) { - return (url, try RecordingTrim.read(url).start, false) + guard operation == expected, active else { return } + try await session?.storyTape("start", arguments: ["id": expected.uuidString]) + guard operation == expected, active else { return } + previousIdleTimer = UIApplication.shared.isIdleTimerDisabled; UIApplication.shared.isIdleTimerDisabled = true + recording = true; busy = true; stage = .rendering + deadline = Task { [weak self] in + try? await Task.sleep(for: .seconds(120)) + guard !Task.isCancelled, let self, self.operation == expected else { return } + self.fail("This card took too long to export. Try again.") } - return (try await StorySpeech.render(row.utterance), 0, true) + } catch { if operation == expected { fail(error.localizedDescription) } } + } + func pause(_ paused: Bool) { + guard recording else { return } + let expected = operation + Task { + guard operation == expected else { return } + try? await session?.storyTape(paused ? "pause" : "resume") } - narration.append((at, task)) } - func finish() { - guard recording else { return }; recording = false - stage = .finishingVideo + func finishCard() async { + guard recording else { assembleIfReady(); return } + recording = false; stage = .finishingVideo let expected = operation - Task { do { try await session?.storyTape("stop") } catch { if operation == expected { fail(error.localizedDescription) } } } + await withCheckedContinuation { continuation in + completion = continuation + Task { + guard operation == expected else { return } + do { try await session?.storyTape("stop") } + catch { if operation == expected { fail(error.localizedDescription) } } + } + } + } + func request() { + if let readyURL { movie = StoryMovie(url: readyURL); return } + requested = true; active = true; busy = true; error = "" + assembleIfReady() + } + func needsRestart(before index: Int) -> Bool { + !recording && assembly == nil || rows.prefix(index).contains { cached($0) == nil } + } + func skipCard() { if raw != nil || recording { discardCard() } } + func discardCard() { + operation = UUID(); exportSession?.cancelExport(); exportSession = nil + cleanupCard() + let current = session, earlier = reset + reset = Task { await earlier?.value; try? await current?.storyTape("cancel") } + } + func cancel() { + active = false; storyRun = UUID(); assembly?.cancel(); assembly = nil + discardCard(); busy = false; requested = false + } + func clearMovie() { movie = nil } + private func cleanupCard() { + if let previousIdleTimer { UIApplication.shared.isIdleTimerDisabled = previousIdleTimer }; previousIdleTimer = nil + deadline?.cancel(); deadline = nil + try? file?.close(); file = nil + if let raw { try? FileManager.default.removeItem(at: raw) }; raw = nil + audio = nil; currentKey = nil; session?.storyTapeEvent = nil; recording = false + completion?.resume(); completion = nil + } + private func fail(_ message: String) { + let showError = requested + cancel() + // Automatic preparation must not interrupt browsing with a modal alert. + if showError { error = message } } private func receive(_ event: [String: Any]) { - guard busy, event["session"] as? String == operation.uuidString else { return } + guard active, event["session"] as? String == operation.uuidString else { return } do { switch event["kind"] as? String { case "chunk": guard let value = event["data"] as? String, value.count <= 300_000, let data = Data(base64Encoded: value) else { throw ExportError.invalidChunk } - bytes += data.count; guard bytes <= 256_000_000 else { throw ExportError.tooLarge } + bytes += data.count; guard bytes <= 128_000_000 else { throw ExportError.tooLarge } try file?.write(contentsOf: data) case "done": try file?.close(); file = nil let expected = operation + guard let raw, let key = currentKey else { throw ExportError.noVideo } + let sound = audio Task { do { - guard let raw else { throw ExportError.noVideo } - let output = try await mux(raw, operation: expected) - guard operation == expected else { try? FileManager.default.removeItem(at: output); return } - #if DEBUG - if NativeScreenFixture.enabled && NativeScreenFixture.mode == "story" { - let evidence = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("story-export-test.mp4") - try? FileManager.default.removeItem(at: evidence); try FileManager.default.copyItem(at: output, to: evidence) - } - #endif - sharedURL = output; movie = StoryMovie(url: output); cleanup(); busy = false; recording = false + let output = try await compose([(raw, sound)], expected: expected, isCard: true) + defer { try? FileManager.default.removeItem(at: output) } + guard operation == expected, active else { return } + _ = try cache.store(output, key: key, protecting: Set(keys.values)) + completedCards = rows.filter { cached($0) != nil }.count + cleanupCard(); stage = .preparing; assembleIfReady() } catch { if operation == expected { fail(error.localizedDescription) } } } case "error": fail(event["error"] as? String ?? "Canvas recording failed.") @@ -105,114 +177,92 @@ struct StoryMovie: Identifiable { let id = UUID(); let url: URL } } } catch { fail(error.localizedDescription) } } - private func cleanup() { - if let previousIdleTimer { UIApplication.shared.isIdleTimerDisabled = previousIdleTimer }; previousIdleTimer = nil - deadline?.cancel(); try? file?.close(); file = nil - if let raw { try? FileManager.default.removeItem(at: raw) }; raw = nil - session?.storyTapeEvent = nil - for item in narration { Task { if let (url, _, temporary) = try? await item.task.value, temporary { try? FileManager.default.removeItem(at: url) } } } - narration = [] + private func assembleIfReady() { + guard active, readyURL == nil, assembly == nil, !rows.isEmpty else { return } + let clips = rows.compactMap { cached($0) } + guard clips.count == rows.count else { return } + let expected = operation, run = storyRun, key = storyKey + stage = .encoding; progress = 0 + assembly = Task { + defer { if storyRun == run { assembly = nil } } + do { + let output = try await compose(clips.map { ($0, nil) }, expected: expected, isCard: false) + defer { try? FileManager.default.removeItem(at: output) } + guard storyRun == run, active else { return } + let cached = try cache.store(output, key: key) + #if DEBUG + if NativeScreenFixture.enabled && NativeScreenFixture.mode == "story" { + let evidence = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("story-export-test.mp4") + try? FileManager.default.removeItem(at: evidence); try FileManager.default.copyItem(at: cached, to: evidence) + } + #endif + readyURL = cached; busy = false + if requested { requested = false; movie = StoryMovie(url: cached) } + } catch { if storyRun == run { fail(error.localizedDescription) } } + } } - private func fail(_ message: String) { cancel(); error = message } - func cancel() { - operation = UUID(); exportSession?.cancelExport(); exportSession = nil - let current = session - Task { try? await current?.storyTape("cancel") } - cleanup(); busy = false; recording = false - } - func clearMovie() { if let sharedURL { try? FileManager.default.removeItem(at: sharedURL) }; sharedURL = nil; movie = nil } - private func checkOperation(_ expected: UUID) throws { - guard busy, operation == expected else { throw CancellationError() } - } - private func mux(_ raw: URL, operation expected: UUID) async throws -> URL { - try checkOperation(expected) - stage = .narration; progress = 0 - let asset = AVURLAsset(url: raw), mix = AVMutableComposition() - let duration = try await asset.load(.duration) - try checkOperation(expected) - guard let video = try await asset.loadTracks(withMediaType: .video).first, - let destination = mix.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { throw ExportError.noVideo } - try destination.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: video, at: .zero) - destination.preferredTransform = try await video.load(.preferredTransform) - let clips = narration - for (index, clip) in clips.enumerated() { - let (url, trim, _) = try await clip.task.value - try checkOperation(expected) - let audio = AVURLAsset(url: url) - guard let track = try await audio.loadTracks(withMediaType: .audio).first, - let target = mix.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else { continue } - let length = try await audio.load(.duration).seconds - trim - let end = index+1 < clips.count ? clips[index+1].at : duration.seconds - let seconds = min(length, end-clip.at, duration.seconds-clip.at) - if seconds > 0 { try target.insertTimeRange(CMTimeRange(start: CMTime(seconds: trim, preferredTimescale: 600), duration: CMTime(seconds: seconds, preferredTimescale: 600)), of: track, at: CMTime(seconds: clip.at, preferredTimescale: 600)) } - progress = Double(index + 1) / Double(clips.count) + private func check(_ expected: UUID) throws { + guard active, operation == expected, !Task.isCancelled else { throw CancellationError() } + } + private func compose(_ clips: [(URL, StoryAudio?)], expected: UUID, isCard: Bool) async throws -> URL { + try check(expected) + let mix = AVMutableComposition() + guard let video = mix.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else { throw ExportError.noVideo } + var cursor = CMTime.zero + for (url, narration) in clips { + let asset = AVURLAsset(url: url), length = try await asset.load(.duration) + try check(expected) + guard let track = try await asset.loadTracks(withMediaType: .video).first else { throw ExportError.noVideo } + try video.insertTimeRange(CMTimeRange(start: .zero, duration: length), of: track, at: cursor) + video.preferredTransform = try await track.load(.preferredTransform) + let soundAsset = narration.map { AVURLAsset(url: $0.url) } ?? asset + if let sound = try await soundAsset.loadTracks(withMediaType: .audio).first, + let target = mix.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) { + let trim = narration?.start ?? 0 + let soundDuration = try await soundAsset.load(.duration).seconds + let seconds = min(length.seconds, (narration?.end ?? soundDuration) - trim) + if seconds > 0 { try target.insertTimeRange(CMTimeRange(start: CMTime(seconds: trim, preferredTimescale: 600), duration: CMTime(seconds: seconds, preferredTimescale: 600)), of: sound, at: cursor) } + } + cursor = cursor + length + guard cursor.seconds <= 600 else { throw ExportError.tooLong } } - try checkOperation(expected) - guard let exporter = AVAssetExportSession(asset: mix, presetName: AVAssetExportPresetHighestQuality) else { throw ExportError.noVideo } + try check(expected) + guard let exporter = AVAssetExportSession(asset: mix, presetName: isCard ? AVAssetExportPresetHighestQuality : AVAssetExportPresetPassthrough) else { throw ExportError.noVideo } let output = FileManager.default.temporaryDirectory.appendingPathComponent("Whistlegraph-\(UUID()).mp4") exporter.outputURL = output; exporter.outputFileType = .mp4; exporter.shouldOptimizeForNetworkUse = true; exportSession = exporter stage = .encoding; progress = 0 let monitor = Task { [weak self] in while !Task.isCancelled { - guard let self, self.operation == expected, self.busy else { return } + guard let self, self.operation == expected, self.active else { return } self.progress = Double(exporter.progress) try? await Task.sleep(for: .milliseconds(150)) } } defer { monitor.cancel() } await exporter.export() - guard operation == expected else { try? FileManager.default.removeItem(at: output); throw CancellationError() } - exportSession = nil - guard exporter.status == .completed else { try? FileManager.default.removeItem(at: output); throw exporter.error ?? ExportError.noVideo } - progress = 1 - return output + do { + try check(expected); exportSession = nil + guard exporter.status == .completed else { throw exporter.error ?? ExportError.noVideo } + progress = 1; return output + } catch { try? FileManager.default.removeItem(at: output); throw error } } private enum ExportError: LocalizedError { - case noVideo, invalidChunk, tooLarge + case noVideo, invalidChunk, tooLarge, tooLong var errorDescription: String? { - switch self { case .noVideo: return "Could not create the MP4."; case .invalidChunk: return "Invalid canvas tape data."; case .tooLarge: return "This story is too large. Export a shorter branch." } - } - } -} - -// Render fallback narration to an audio file, never through a microphone. -@MainActor private final class StorySpeech { - private let voice = AVSpeechSynthesizer() - private var file: AVAudioFile? - private var continuation: CheckedContinuation? - private var timeout: Task? - private let url = FileManager.default.temporaryDirectory.appendingPathComponent("story-voice-\(UUID()).caf") - static func render(_ text: String) async throws -> URL { - let renderer = StorySpeech() - return try await renderer.render(text) - } - private func render(_ text: String) async throws -> URL { - try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation - self.timeout = Task { - try? await Task.sleep(for: .seconds(45)) - guard !Task.isCancelled, let pending = self.continuation else { return } - self.continuation = nil; self.voice.stopSpeaking(at: .immediate) - pending.resume(throwing: NSError(domain: "StorySpeech", code: 1, userInfo: [NSLocalizedDescriptionKey: "Narration rendering timed out."])) - } - let line = AVSpeechUtterance(string: text.isEmpty ? " " : text) - line.rate = AVSpeechUtteranceDefaultSpeechRate; line.voice = AVSpeechSynthesisVoice(language: "en-US") - voice.write(line) { buffer in - guard let pcm = buffer as? AVAudioPCMBuffer else { return } - Task { @MainActor in - guard let continuation = self.continuation else { return } - do { - if pcm.frameLength == 0 { self.timeout?.cancel(); self.continuation = nil; self.file = nil; continuation.resume(returning: self.url); return } - if self.file == nil { self.file = try AVAudioFile(forWriting: self.url, settings: pcm.format.settings) } - try self.file?.write(from: pcm) - } catch { self.timeout?.cancel(); self.continuation = nil; continuation.resume(throwing: error) } - } + switch self { + case .noVideo: return "Could not create the MP4." + case .invalidChunk: return "Invalid canvas tape data." + case .tooLarge: return "This card is too large to export." + case .tooLong: return "Export a story shorter than ten minutes." } } } } + +#if canImport(UIKit) struct StoryShareSheet: UIViewControllerRepresentable { let url: URL func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: [url], applicationActivities: nil) } func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} } +#endif diff --git a/apple/whistlegraph/Sources/StoryMovieSheet.swift b/apple/whistlegraph/Sources/StoryMovieSheet.swift index cb8997983e..8dd9d97f4f 100644 --- a/apple/whistlegraph/Sources/StoryMovieSheet.swift +++ b/apple/whistlegraph/Sources/StoryMovieSheet.swift @@ -4,32 +4,24 @@ import Photos struct StoryMovieSheet: View { let url: URL - @Environment(\.dismiss) private var dismiss - @State private var player: AVPlayer? @State private var sharing = false @State private var saving = false @State private var saved = false @State private var error = "" var body: some View { - NavigationStack { - VStack(spacing: 18) { - VideoPlayer(player: player).aspectRatio(9.0 / 16.0, contentMode: .fit) - HStack(spacing: 16) { - Button { save() } label: { - HStack { - if saving { ProgressView() } - Label(saved ? "Saved to Photos" : saving ? "Saving…" : "Save video", systemImage: saved ? "checkmark" : "square.and.arrow.down") - } - .frame(maxWidth: .infinity) - }.buttonStyle(.borderedProminent).disabled(saved || saving).accessibilityIdentifier("story-save-video") - Button { sharing = true } label: { Label("Share", systemImage: "square.and.arrow.up") }.buttonStyle(.bordered) - } - }.padding().navigationTitle("Your video").navigationBarTitleDisplayMode(.inline) - .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() }.disabled(saving) } } + VStack(spacing: 12) { + Button { save() } label: { + HStack { + if saving { ProgressView() } + Label(saved ? "Saved to Photos" : saving ? "Saving…" : "Save video", systemImage: saved ? "checkmark" : "square.and.arrow.down") + }.frame(maxWidth: .infinity, minHeight: 44) + }.buttonStyle(.borderedProminent).disabled(saved || saving).accessibilityIdentifier("story-save-video") + Button { sharing = true } label: { + Label("Share MP4", systemImage: "square.and.arrow.up").frame(maxWidth: .infinity, minHeight: 44) + }.buttonStyle(.bordered).disabled(saving).accessibilityIdentifier("story-share-video") } + .padding(16).frame(width: 280).accessibilityIdentifier("story-movie-popover") .interactiveDismissDisabled(saving) - .onAppear { player = AVPlayer(url: url) } - .onDisappear { player?.pause() } .sheet(isPresented: $sharing) { StoryShareSheet(url: url) } .alert("Could not save video", isPresented: Binding(get: { !error.isEmpty }, set: { if !$0 { error = "" } })) { Button("OK") { error = "" } diff --git a/apple/whistlegraph/Sources/StoryVoice.swift b/apple/whistlegraph/Sources/StoryVoice.swift new file mode 100644 index 0000000000..60e7bc5aa8 --- /dev/null +++ b/apple/whistlegraph/Sources/StoryVoice.swift @@ -0,0 +1,87 @@ +import AVFoundation + +struct StoryAudio { + let url: URL + let start: Double + let end: Double + let original: Bool +} + +@MainActor enum StoryVoice { + static let cache = StoryCache(name: "StoryVoice", limit: 32_000_000) + private static var pending: [String: Task] = [:] + + static func audio(for row: PieceRevision) async throws -> StoryAudio? { + if let id = row.recordingID, let url = UtteranceRecording.url(id), FileManager.default.fileExists(atPath: url.path), let trim = try? RecordingTrim.read(url) { + return StoryAudio(url: url, start: trim.start, end: trim.end, original: true) + } + guard !row.utterance.isEmpty else { return nil } + #if DEBUG + if NativeScreenFixture.enabled, let url = Bundle.main.url(forResource: "basic-request", withExtension: "wav", subdirectory: "Fixtures") { + return StoryAudio(url: url, start: 0, end: try AVAudioPlayer(contentsOf: url).duration, original: false) + } + #endif + let url = try await rendered(row.utterance) + return StoryAudio(url: url, start: 0, end: try AVAudioPlayer(contentsOf: url).duration, original: false) + } + + static func rendered(_ text: String) async throws -> URL { + let key = StoryCache.key(["jeffrey-pvc-v1", text]) + if let url = cache.find(key, ext: "mp3") { return url } + if let task = pending[key] { return try await task.value } + let task = Task { + var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/say")!) + request.httpMethod = "POST"; request.timeoutInterval = 20 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["from": text, "provider": "jeffrey", "voice": "neutral:0", "speed": 1]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, response.statusCode == 200, data.count > 0, data.count < 8_000_000 else { throw VoiceError.unavailable } + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".mp3") + defer { try? FileManager.default.removeItem(at: temporary) } + try data.write(to: temporary, options: .atomic) + guard try AVAudioPlayer(contentsOf: temporary).duration > 0 else { throw VoiceError.unavailable } + return try cache.store(temporary, key: key, ext: "mp3") + } + pending[key] = task + defer { pending[key] = nil } + do { return try await task.value } + catch { + // Offline exports remain usable. Never cache the fallback as Jeffrey: + // reconnecting must retry the requested voice. + return try await DeviceStorySpeech.render(text) + } + } + private enum VoiceError: Error { case unavailable } +} + +@MainActor private final class DeviceStorySpeech { + private let voice = AVSpeechSynthesizer() + private var file: AVAudioFile? + private var continuation: CheckedContinuation? + private var timeout: Task? + private let url = FileManager.default.temporaryDirectory.appendingPathComponent("story-voice-\(UUID()).caf") + static func render(_ text: String) async throws -> URL { try await DeviceStorySpeech().render(text) } + private func render(_ text: String) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + timeout = Task { + try? await Task.sleep(for: .seconds(15)) + guard !Task.isCancelled, let pending = self.continuation else { return } + self.continuation = nil; self.voice.stopSpeaking(at: .immediate) + pending.resume(throwing: NSError(domain: "StoryVoice", code: 1, userInfo: [NSLocalizedDescriptionKey: "Narration is unavailable. Try again when connected."])) + } + let line = AVSpeechUtterance(string: text); line.voice = AVSpeechSynthesisVoice(language: "en-US") + voice.write(line) { buffer in + guard let pcm = buffer as? AVAudioPCMBuffer else { return } + Task { @MainActor in + guard let continuation = self.continuation else { return } + do { + if pcm.frameLength == 0 { self.timeout?.cancel(); self.continuation = nil; self.file = nil; continuation.resume(returning: self.url); return } + if self.file == nil { self.file = try AVAudioFile(forWriting: self.url, settings: pcm.format.settings) } + try self.file?.write(from: pcm) + } catch { self.timeout?.cancel(); self.continuation = nil; continuation.resume(throwing: error) } + } + } + } + } +} diff --git a/apple/whistlegraph/Sources/VersionNarrator.swift b/apple/whistlegraph/Sources/VersionNarrator.swift index cc8e4af604..e2f9a1d395 100644 --- a/apple/whistlegraph/Sources/VersionNarrator.swift +++ b/apple/whistlegraph/Sources/VersionNarrator.swift @@ -1,7 +1,7 @@ import SwiftUI import AVFoundation -@MainActor final class VersionNarrator: NSObject, ObservableObject, AVSpeechSynthesizerDelegate, AVAudioPlayerDelegate { +@MainActor final class VersionNarrator: NSObject, ObservableObject, AVAudioPlayerDelegate { @Published var isPlaying = false @Published var isPaused = false @Published var utterance = "" @@ -12,7 +12,12 @@ import AVFoundation @Published private(set) var count = 0 @Published private(set) var progress = 0.0 var onComplete: (() -> Void)? - var onNarration: ((PieceRevision) async -> Void)? + var onNarration: ((PieceRevision, StoryAudio?) async -> Void)? + var onCardComplete: (() async -> Void)? + var onSkip: (() -> Void)? + var onPause: ((Bool) -> Void)? + var shouldPlay: ((Int) -> Bool)? + var branch: [PieceRevision] { versions } private var words: [PlaybackWord] = [] private var clock: Task? private var recordingID: String? @@ -22,15 +27,13 @@ import AVFoundation private var elapsed = 0.0 private var duration = 4.0 private var tail: Double? - private let voice = AVSpeechSynthesizer() private weak var session: WhistlegraphSession? private var versions: [PieceRevision] = [] private var waiting: Int? - private var spoken: AVSpeechUtterance? private var timer: Task? private var run = UUID() private var completed = false - override init() { super.init(); voice.delegate = self } + override init() { super.init() } func play(_ session: WhistlegraphSession) { stop() @@ -52,25 +55,23 @@ import AVFoundation } func next() { guard isPlaying else { return } + onSkip?() if index + 1 < count { index += 1; show() } else { setPaused(true); progress = 1 } } - func previous() { guard isPlaying else { return }; index = max(0, index - 1); show() } - func restart() { guard isPlaying else { return }; index = 0; isPaused = false; show() } + func previous() { guard isPlaying else { return }; onSkip?(); index = max(0, index - 1); show() } + func restart() { guard isPlaying else { return }; onSkip?(); index = 0; isPaused = false; show() } + func jump(to target: Int) { guard isPlaying, versions.indices.contains(target) else { return }; onSkip?(); index = target; isPaused = false; show() } func setPaused(_ paused: Bool) { guard isPlaying else { return } isPaused = paused - if paused { player?.pause(); voice.pauseSpeaking(at: .immediate) } + onPause?(paused) + if paused { player?.pause() } else if completed { restart() } - else { - player?.play() - if voice.isPaused { voice.continueSpeaking() } - else if !voice.isSpeaking, let spoken { voice.speak(spoken) } - } + else { player?.play() } } private func clearPlayback() { clock?.cancel(); timer?.cancel(); run = UUID(); waiting = nil - spoken = nil; voice.stopSpeaking(at: .immediate) player?.stop(); player = nil; currentWord = ""; spokenRange = nil } private func show() { @@ -91,33 +92,33 @@ import AVFoundation waiting = nil; timer?.cancel() let expected = run, row = versions[index] Task { - await onNarration?(row) + let sound: StoryAudio? + do { sound = try await StoryVoice.audio(for: row) } + catch { + guard run == expected, isPlaying else { return } + stop(); self.error = "Could not prepare narration. Try again when connected."; return + } guard run == expected, isPlaying else { return } - startNarration() + await onNarration?(row, sound) + guard run == expected, isPlaying else { return } + startNarration(sound) + if isPaused { onPause?(true) } } } - private func startNarration() { - duration = max(4, Double(utterance.split(separator: " ").count) / 2.5 + 1.5) + private func startNarration(_ sound: StoryAudio?) { + duration = 4 do { let audio = AVAudioSession.sharedInstance() try audio.setCategory(.playback, mode: .default); try audio.setActive(true) - if let recordingID, let url = UtteranceRecording.url(recordingID), FileManager.default.fileExists(atPath: url.path) { - let original = try AVAudioPlayer(contentsOf: url), trim = try RecordingTrim.read(url) - original.currentTime = trim.start; playbackStart = trim.start; playbackEnd = trim.end - duration = max(4, trim.end - trim.start + 1.5) - original.delegate = self; player = original; original.prepareToPlay() - if !isPaused { original.play() } - } - } catch { self.error = "Original recording unavailable; reading the transcript." } - if player == nil { - if utterance.isEmpty { tail = 4 } - else { - let line = AVSpeechUtterance(string: utterance) - line.rate = AVSpeechUtteranceDefaultSpeechRate; line.voice = AVSpeechSynthesisVoice(language: "en-US") - spoken = line - if !isPaused { voice.speak(line) } - } - } + if let sound { + let recording = try AVAudioPlayer(contentsOf: sound.url) + recording.currentTime = sound.start; playbackStart = sound.start; playbackEnd = sound.end + duration = max(4, sound.end - sound.start + 1.5) + if !sound.original { words = [] } + recording.delegate = self; player = recording; recording.prepareToPlay() + if !isPaused { recording.play() } + } else { tail = 4 } + } catch { stop(); self.error = "Could not play narration."; return } let expected = run clock = Task { [weak self] in var last = Date() @@ -142,18 +143,12 @@ import AVFoundation } private func narrationFinished() { currentWord = ""; spokenRange = nil; tail = max(1.5, 4 - elapsed); duration = elapsed + (tail ?? 1.5) } private func finishCard() { - if index + 1 < count { index += 1; show() } - else { completed = true; isPaused = true; progress = 1; onComplete?() } - } - nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString range: NSRange, utterance: AVSpeechUtterance) { - Task { @MainActor [weak self] in - guard let self, self.spoken === utterance else { return } - self.currentWord = (utterance.speechString as NSString).substring(with: range); self.spokenRange = range - } - } - nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { - Task { @MainActor [weak self] in - guard let self, self.spoken === utterance else { return }; self.spoken = nil; self.narrationFinished() + let expected = run + Task { + await onCardComplete?() + guard run == expected, isPlaying else { return } + if let next = ((index + 1).. Void)? + var session = "" + var starts = 0 + let fixture: Data + init(fixture: Data) { self.fixture = fixture } + func storyTape(_ action: String, arguments: [String: Any] = [:]) async throws { + if action == "start" { session = arguments["id"] as! String; starts += 1 } + if action == "stop" { + for offset in stride(from: 0, to: fixture.count, by: 192_000) { + storyTapeEvent?(["session": session, "kind": "chunk", "data": fixture.subdata(in: offset.. { assert.ok(inferenceBodies[1].messages.some(m=>JSON.stringify(m).includes('REPAIR THIS CANDIDATE ONCE'))); assert.ok(inferenceBodies.every(b=>JSON.stringify(b.messages).includes('DRAWING REFERENCE')),'both passes retain gesture intent'); assert.ok(inferenceBodies.every(b=>b.messages.some(m=>Array.isArray(m.content)&&m.content.some(c=>c.type==='image'))),'initial and repair passes retain chalk pixels'); - assert.ok(inferenceBodies.every(b=>b.max_tokens===4096&&b.thinking.type==='disabled')); + assert.ok(inferenceBodies.every(b=>b.max_tokens===4096)); + assert.equal(inferenceBodies[0].model,'deepseek/deepseek-v4.1-flash'); + assert.equal(inferenceBodies[0].thinking.type,'disabled'); + assert.equal(inferenceBodies[1].model,'deepseek/deepseek-v4-pro'); + assert.equal(inferenceBodies[1].thinking.budget_tokens,1024); const receipt=await page.evaluate(()=>JSON.parse(localStorage.getItem('walkieware-source-receipts')).at(-1).receipt); assert.equal(receipt.repairs,1);assert.equal(receipt.rounds.length,process.argv.includes('--repair-fails')?2:3);assert.equal(receipt.rounds[0].reportedModel,'fixture/reported'); assert.equal(receipt.rounds[0].providerRequestID,'provider-1');assert.equal(receipt.rounds[1].usage.costUSD,.001); diff --git a/apple/whistlegraph/Tests/prompt-limit.test.mjs b/apple/whistlegraph/Tests/prompt-limit.test.mjs new file mode 100644 index 0000000000..a4d710623b --- /dev/null +++ b/apple/whistlegraph/Tests/prompt-limit.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {checkedPrompt} from '../Resources/Web/prompt-limit.mjs'; +test('remote requests use the same 96 grapheme limit as iPhone typing', () => { + assert.equal(checkedPrompt('a'.repeat(96)).length, 96); + assert.throws(() => checkedPrompt('a'.repeat(97)), /96 characters/); + const family = '👨‍👩‍👧‍👦'; + assert.equal(checkedPrompt(family.repeat(96)), family.repeat(96)); + assert.throws(() => checkedPrompt(family.repeat(97)), /96 characters/); + assert.equal(checkedPrompt(' fix\nthe leaf '), 'fix the leaf'); + assert.throws(() => checkedPrompt(' '), /required/); +}); diff --git a/apple/whistlegraph/Tests/story-cache.test.sh b/apple/whistlegraph/Tests/story-cache.test.sh new file mode 100644 index 0000000000..96d708ebf6 --- /dev/null +++ b/apple/whistlegraph/Tests/story-cache.test.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +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; } +"$cache_check_dir/check" "$cache_check_dir/card.mp4" diff --git a/apple/whistlegraph/Tests/story-tape.test.mjs b/apple/whistlegraph/Tests/story-tape.test.mjs new file mode 100644 index 0000000000..377766aeac --- /dev/null +++ b/apple/whistlegraph/Tests/story-tape.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import vm from 'node:vm'; +const source = await readFile(new URL('../Resources/Web/story-tape.js', import.meta.url), 'utf8'); +function fixture(font = Promise.resolve()) { + const messages = [], recorders = []; + const context = {fillRect(){}, drawImage(){}, fillText(){}, measureText:s=>({width:s.length * 20})}; + const layer = {width:320}; + const window = {top:{}, whistlegraphStoryFont:font, webkit:{messageHandlers:{walkie:{postMessage:m=>messages.push(m)}}}}; + vm.runInNewContext(source, { + window, location:{origin:'https://aesthetic.computer'}, Uint8Array, + document:{createElement:()=>({getContext:()=>context}),querySelector:()=>layer,querySelectorAll:()=>[]}, + getComputedStyle:()=>({display:'block'}),setInterval:()=>1,clearInterval(){},btoa:s=>Buffer.from(s,'binary').toString('base64'), + createCanvasTapeRecorder:()=>{ + const recorder={state:'inactive',start(){this.state='recording';},pause(){this.state='paused';},resume(){this.state='recording';},stop(){this.state='inactive';this.onstop?.();}}; + recorders.push(recorder);return recorder; + } + }); + return {tape:window.whistlegraphStoryTape,recorders,messages}; +} +const drain = () => new Promise(resolve=>setImmediate(resolve)); +test('pause/resume omits pauses from the recorded card and completion keeps its identity',async()=>{ + const {tape,recorders,messages}=fixture();await tape.start('one'); + tape.pause();assert.equal(recorders[0].state,'paused'); + tape.resume();assert.equal(recorders[0].state,'recording'); + tape.stop();await drain(); + assert.equal(messages.at(-1).kind,'done');assert.equal(messages.at(-1).session,'one'); + await tape.start('two');assert.equal(recorders.length,2);tape.cancel(); +}); +test('cancel during font loading cannot start a late encoder',async()=>{ + let release;const font=new Promise(resolve=>release=resolve); + const {tape,recorders}=fixture(font);const starting=tape.start('old'); + tape.cancel();release();await starting;assert.equal(recorders.length,0); + await tape.start('new');assert.equal(recorders.length,1);tape.cancel(); +}); +test('a cancelled chunk cannot leak into the next card or finish it',async()=>{ + let release;const bytes=new Promise(resolve=>release=resolve); + const {tape,recorders,messages}=fixture();await tape.start('old'); + recorders[0].ondataavailable({data:{size:1,arrayBuffer:()=>bytes}}); + await drain();tape.stop();tape.cancel();await tape.start('new'); + release(new Uint8Array([1]).buffer);await drain();assert.equal(messages.length,0); + tape.stop();await drain();assert.deepEqual(messages.map(m=>[m.session,m.kind]),[['new','done']]); +}); diff --git a/apple/whistlegraph/UITests/StoryCardsTests.swift b/apple/whistlegraph/UITests/StoryCardsTests.swift index 62f59141a3..76f9811003 100644 --- a/apple/whistlegraph/UITests/StoryCardsTests.swift +++ b/apple/whistlegraph/UITests/StoryCardsTests.swift @@ -4,6 +4,7 @@ final class StoryCardsTests: XCTestCase { func testExportStoryMP4OnPhone() throws { let app = XCUIApplication() app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "story" + app.launchEnvironment["WALKIE_RESET_STORY_CACHE"] = "1" app.launch() let cards = app.buttons["play-versions"] XCTAssertTrue(cards.waitForExistence(timeout: 30)) @@ -20,6 +21,9 @@ final class StoryCardsTests: XCTestCase { let rendering = XCTAttachment(screenshot: app.screenshot()); rendering.name = "MP4 rendering progress"; rendering.lifetime = .keepAlways; add(rendering) let copy = app.buttons["story-save-video"] XCTAssertTrue(copy.waitForExistence(timeout: 90), app.debugDescription) + let popover = app.otherElements["story-movie-popover"] + XCTAssertTrue(popover.exists) + XCTAssertLessThan(popover.frame.height, app.frame.height / 2, "Save actions stay in a compact popover") let shared = XCTAttachment(screenshot: app.screenshot()); shared.name = "MP4 ready to save"; shared.lifetime = .keepAlways; add(shared) app.terminate() } @@ -27,6 +31,7 @@ final class StoryCardsTests: XCTestCase { func testCancelExportRestoresStoryControls() { let app = XCUIApplication() app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "story" + app.launchEnvironment["WALKIE_RESET_STORY_CACHE"] = "1" app.launch() let cards = app.buttons["play-versions"] XCTAssertTrue(cards.waitForExistence(timeout: 30)) @@ -43,6 +48,32 @@ final class StoryCardsTests: XCTestCase { app.terminate() } + func testCardsPrepareVideoWithoutExportAndReuseItAfterRelaunch() { + let app = XCUIApplication() + app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "story" + app.launchEnvironment["WALKIE_RESET_STORY_CACHE"] = "1" + app.launch() + let cards = app.buttons["play-versions"] + XCTAssertTrue(cards.waitForExistence(timeout: 30)) + expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: cards) + waitForExpectations(timeout: 60); cards.tap() + let status = app.staticTexts["story-video-status"] + expectation(for: NSPredicate(format: "label == %@", "Video ready"), evaluatedWith: status) + waitForExpectations(timeout: 90) + XCTAssertFalse(app.buttons["story-save-video"].exists, "Background preparation never interrupts with a popover") + app.terminate(); app.launchEnvironment["WALKIE_RESET_STORY_CACHE"] = "0"; app.launch() + XCTAssertTrue(cards.waitForExistence(timeout: 30)) + expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: cards) + waitForExpectations(timeout: 60); cards.tap() + expectation(for: NSPredicate(format: "label == %@", "Video ready"), evaluatedWith: status) + waitForExpectations(timeout: 3) + app.buttons["story-export"].tap() + XCTAssertTrue(app.buttons["story-save-video"].waitForExistence(timeout: 3)) + XCTAssertFalse(app.otherElements["story-export-progress"].exists) + let image = XCTAttachment(screenshot: app.screenshot()); image.name = "Cached MP4 popover"; image.lifetime = .keepAlways; add(image) + app.terminate() + } + func testCardsKeepCaptionsBelowPictureAndRestoreSelection() { let app = XCUIApplication() app.launchEnvironment["WALKIE_NATIVE_SCREEN_FIXTURE"] = "history" diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index 91bb0fdf7e..ececc01e20 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 123228FADA6696AB354306C7 /* InputButtonLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35643029A3E82EEDEAC8DED6 /* InputButtonLabels.swift */; }; 13604BE92157CDBFDD32AA19 /* WorkspaceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 147EAC245AD250B9B652F903 /* WorkspaceCoordinator.swift */; }; 14FFBE48767E2576693FB7B3 /* MusicalInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D233DBB6F87184A91C13D41 /* MusicalInput.swift */; }; + 15EE658816EEA4B8BE6AAF5A /* StoryVoice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49854384EFBA11ABDE4F70C6 /* StoryVoice.swift */; }; 19717448D7598ADAABBF739E /* HeaderSheetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F98F7D3CE551FC6458D1F37 /* HeaderSheetsTests.swift */; }; 26D07334E2DEBA6705671BB0 /* WhistlegraphApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA5F188938A81E704BB7F9F9 /* WhistlegraphApp.swift */; }; 275555C74507A6F62B0F037B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF5F023E15041FF61F9FD358 /* Assets.xcassets */; }; @@ -28,6 +29,7 @@ 749AC25AE02AAE4BCDA5683A /* VisualCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF0A171A5771A7A1FD56852 /* VisualCapture.swift */; }; 77291EEE768914D5F5EDB84B /* PlaybackWord.swift in Sources */ = {isa = PBXBuildFile; fileRef = E97104E13862EE30444D07C9 /* PlaybackWord.swift */; }; 77EC5FCFB7F9BF4A6BD4F94B /* NativeSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D4A924CCE7B4BEC62E98A9 /* NativeSignIn.swift */; }; + 7C8A0DCC5322399983CC8E43 /* StoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E86622F951A1F67F055D69F9 /* StoryCache.swift */; }; 87EDDAE34B992D308236F1A9 /* StoryCardsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B4655A00B6DC810F7203D1 /* StoryCardsTests.swift */; }; 8A9F944BA95C971D70ADDF6B /* VersionNarrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D137824491A01D4C19417B8 /* VersionNarrator.swift */; }; 9DBA1AF2AD7E3EDF69C8132A /* CodeTicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E2AAA0A3F2BA1A5AEBED58 /* CodeTicker.swift */; }; @@ -67,6 +69,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 = ""; }; + 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 = ""; }; 4D3B156A3435E646EE0E6EEA /* PlaybackCaption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCaption.swift; sourceTree = ""; }; @@ -90,6 +93,7 @@ D25F85E540016CEBC3A6BC09 /* WhistlegraphTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphTheme.swift; sourceTree = ""; }; E38DD74717D0CE306D97D1CC /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Fixtures; path = Resources/Fixtures; sourceTree = SOURCE_ROOT; }; E66B3ADC4D6D837FABC53B8F /* WhistlegraphScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhistlegraphScreen.swift; sourceTree = ""; }; + E86622F951A1F67F055D69F9 /* StoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryCache.swift; sourceTree = ""; }; E97104E13862EE30444D07C9 /* PlaybackWord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackWord.swift; sourceTree = ""; }; FF5F023E15041FF61F9FD358 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ @@ -132,10 +136,12 @@ CC1EFF8BAE7678E320E3DCA0 /* RecordingTrim.swift */, B55FB4AA387E716F9FEE0F83 /* SequenceBenchmark.swift */, B7A25F803FAC121BFECC1AD7 /* SpeechCapture.swift */, + E86622F951A1F67F055D69F9 /* StoryCache.swift */, 8C42750EF4F7E96146B03585 /* StoryControls.swift */, 88B494AD8CD86FCC5DE0E4CC /* StoryExport.swift */, 250E0B1999C7810299ACB7A9 /* StoryMovieSheet.swift */, 03AEEA7D7E12C650CB618757 /* StoryTape.swift */, + 49854384EFBA11ABDE4F70C6 /* StoryVoice.swift */, 4728CB634CB3E4AED4F4D353 /* TalkHoldInput.swift */, 8F9B92C3457F07429C8C2600 /* UtteranceRecording.swift */, 6D137824491A01D4C19417B8 /* VersionNarrator.swift */, @@ -290,10 +296,12 @@ 10C0A16D4803EC60EAC29531 /* RecordingTrim.swift in Sources */, C4390906B03E8E8666BC54A3 /* SequenceBenchmark.swift in Sources */, D3D16A0333764D0E5C3B620D /* SpeechCapture.swift in Sources */, + 7C8A0DCC5322399983CC8E43 /* StoryCache.swift in Sources */, 3CF6178F7BA1702ADFA457B7 /* StoryControls.swift in Sources */, 47DADA794ABD0CD203CFF48C /* StoryExport.swift in Sources */, 03C5B25FC742430AFD02AD1E /* StoryMovieSheet.swift in Sources */, E55F422280707B98E1D3E842 /* StoryTape.swift in Sources */, + 15EE658816EEA4B8BE6AAF5A /* StoryVoice.swift in Sources */, D8E8FFEA41DF3B0073DC5373 /* TalkHoldInput.swift in Sources */, 49D6E6ED3128002039D4F931 /* UtteranceRecording.swift in Sources */, 8A9F944BA95C971D70ADDF6B /* VersionNarrator.swift in Sources */, @@ -416,7 +424,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -501,7 +509,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 95; + CURRENT_PROJECT_VERSION = 96; 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 95973417cb..e6da96cf86 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: "95" + CURRENT_PROJECT_VERSION: "96" targets: Whistlegraph: type: application