diff --git a/docs/development/prototype-teaser-capture.md b/docs/development/prototype-teaser-capture.md new file mode 100644 index 00000000..6585f678 --- /dev/null +++ b/docs/development/prototype-teaser-capture.md @@ -0,0 +1,27 @@ +# Prototype teaser capture + +Owner-requested 24-second local Telegram teaser, using Godot 4.7.2 native Movie Maker and ordinary FFmpeg editing. The route extends the existing machinery integration test: finite source material is extracted, refined and assembled through real commands before the scene is retained. The recorded commands build, dismantle and rotate a wall from one recovered plate, pack and relocate the fitted printer, replace an extractor head with a refinery head, and refine its actual 1 kg ice into water. Final snapshot validation and finite-material checks must pass. + +Existing ProceduralHabitatPreview, MachineryPreview and inventory meshes are rendered from current owner observations. Static close camera shots, capture-only lighting and compact edge captions differ from normal presentation. The normal HUD is hidden so it cannot obscure the machinery or wall. Debug labels, fixture IDs and the preview avatar are hidden. No canonical state grants, fabricated receipts, service listeners, external sends, new fonts/assets, generative image tools or game release are involved. Captions use the existing engine UI font. First-party geometry/capture code is MIT; existing engine font remains part of the engine runtime, not an added redistributed asset. + +Run in a clean isolated worktree after import: + +```powershell +./tools/ci/capture-telegram-teaser.ps1 -Godot 'D:/Godot_v4.7.2-stable_win64.exe/Godot_v4.7.2-stable_win64_console.exe' -OutputDirectory 'D:/GitHub/Frontier Systems/var/captures/telegram-teaser' +``` + +The wrapper owns one bounded native process, disables external telemetry, isolates the user directory and removes its temporary override. It never opens a socket or touches the regular saved world. Capture is graphical on Windows using GL compatibility. Automated fixture and camera operation are disclosed onscreen; this is not footage of human play, controller/VR acceptance or a published build. Local media can be given to the owner for the explicitly requested personal teaser; no Telegram upload is performed by this route. + +Review native keyframes before encoding. Use H.264/yuv420p MP4, 1280x720, 30 fps and faststart for mobile sharing. Keep raw provenance and output hashes alongside ignored local media; do not add current internal gallery images to the teaser or upload it to other services. + +## Resumed camera pass, 2026-10-22 + +The capture branch now includes the current integration branch's improved assets, machinery activity, walker poses and contextual UI. Four deliberate cuts separate construction, printer relocation, head replacement and material recovery. Each shot holds its subject while a small eased dolly adds depth; the last shot reframes toward the rebuilt wall before its placement command. Captions remain at the edges and the normal HUD stays hidden. This is an automated workshop demonstration, not first-person player footage. + +Acceptance requires a new native render showing each commanded object clearly before and after the change, no subject hidden by captions or geometry, smooth within-shot motion, and the existing finite-material assertions passing. Native verification and rendering are deferred while the independently owned Gaia restore holds runtime capacity. The source change alone is not visual acceptance or a finished video. + +## Dependency integration, 2026-10-29 + +Integrated `dev-master` at `5b75fa462f0bab133a3f820c8d8234fdfe4d2f0d` without conflicts. The four shots and genuine command sequence are unchanged. The updated habitat preview hides its obsolete worksite wall through the existing `focus_production()` call. `teaser_choreography_test.gd` exposes the capture script's existing headless path to the standard Windows/Linux CI runner, including inherited machinery assertions and final finite-world checks. It does not render or establish camera quality. + +The retained October 22 capture belongs to `539e78da3686e0b77deb7da5eb041863d5ce0a9a`; it is not visual evidence for this integrated revision. No new local native execution or capture is authorized. Source readiness and CI review are separate from owner motion acceptance and a finished trailer. diff --git a/tests/godot/teaser_choreography_test.gd b/tests/godot/teaser_choreography_test.gd new file mode 100644 index 00000000..ff364c27 --- /dev/null +++ b/tests/godot/teaser_choreography_test.gd @@ -0,0 +1,4 @@ +extends "res://tools/godot/capture_telegram_teaser.gd" + +## Runs the genuine finite workshop choreography through the standard headless +## test runner. This verifies commands and final state, not rendered motion. diff --git a/tools/ci/capture-telegram-teaser.ps1 b/tools/ci/capture-telegram-teaser.ps1 new file mode 100644 index 00000000..902051d8 --- /dev/null +++ b/tools/ci/capture-telegram-teaser.ps1 @@ -0,0 +1,57 @@ +param( + [string]$Godot = 'godot', + [string]$OutputDirectory = '', + [ValidateRange(30, 300)][int]$TimeoutSeconds = 180 +) +# Native non-generative movie capture, no sockets, uploads or persistent game data. +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$executable = (Get-Command $Godot -CommandType Application).Source +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $projectRoot 'var/captures/telegram-teaser' } +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$OutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path +$overridePath = Join-Path $projectRoot 'override.cfg' +if (Test-Path -LiteralPath $overridePath) { throw 'Preserve existing override.cfg; use a clean isolated worktree.' } +$profileName = 'FrontierSystems-teaser-' + [Guid]::NewGuid().ToString('N') +$process = $null +try { + [IO.File]::WriteAllText($overridePath, "[application]`nconfig/use_custom_user_dir=true`nconfig/custom_user_dir_name=`"$profileName`"`n`n[display]`nwindow/size/viewport_width=1280`nwindow/size/viewport_height=720`n") + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $executable + $info.WorkingDirectory = $projectRoot + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.Environment['FRONTIER_EVENT_URL'] = '' + $info.Environment['FRONTIER_EVENT_TOKEN'] = '' + $info.Environment['FRONTIER_TEASER_OUTPUT'] = $OutputDirectory + foreach ($argument in @('--xr-mode','off','--audio-driver','Dummy','--rendering-method','gl_compatibility','--resolution','1280x720','--path',$projectRoot,'--script','res://tools/godot/capture_telegram_teaser.gd','--fixed-fps','30','--write-movie',(Join-Path $OutputDirectory 'raw.avi'))) { + $info.ArgumentList.Add($argument) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + if (-not $process.Start()) { throw 'Native movie capture did not start.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + $process.Kill($true) + $process.WaitForExit() + throw 'Owned movie capture exceeded its deadline.' + } + $log = $stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult() + [IO.File]::WriteAllText((Join-Path $OutputDirectory 'capture.log'), $log) + if ($process.ExitCode -ne 0 -or $log -match '(?m)SCRIPT ERROR|(?:^|\s)ERROR:') { + Write-Host $log.Substring(0, [Math]::Min(3000, $log.Length)) + throw 'Native capture failed; inspect capture.log.' + } + Write-Host "Native capture completed: $OutputDirectory" + $log | Select-String -Pattern 'Machinery integration|TEASER_' +} finally { + if ($null -ne $process) { + if (-not $process.HasExited) { $process.Kill($true) } + $process.Dispose() + } + Remove-Item -LiteralPath $overridePath -ErrorAction SilentlyContinue +} diff --git a/tools/godot/capture_telegram_teaser.gd b/tools/godot/capture_telegram_teaser.gd new file mode 100644 index 00000000..6326691b --- /dev/null +++ b/tools/godot/capture_telegram_teaser.gd @@ -0,0 +1,244 @@ +extends "res://tests/godot/machinery_runtime_test.gd" + +## Genuine native render of the existing finite integration world. +## Only the camera, lighting and caption layer differ from ordinary presentation. +const FPS: int = 30 +const FRAMES: int = 720 +var habitat: ProceduralHabitatPreview +var machinery: MachineryPreview +var title: Label +var detail: Label +var _output: String +var _extractor: String = "" +var _extractor_head: String = "" +var _refinery: String = "" +var _refinery_head: String = "" +var _printer: String = "" +var _plate: String = "" +var _wall: String = "" + + +func finish() -> void: + if failures > 0: + world.free() + quit(1) + return + _prepare_actions() + if DisplayServer.get_name() == "headless": + for frame in range(FRAMES): + _action(frame) + step(2) + _verify_actions() + world.free() + quit(1 if failures else 0) + return + _output = OS.get_environment("FRONTIER_TEASER_OUTPUT") + if _output.is_empty(): _output = ProjectSettings.globalize_path("res://var/captures/telegram-teaser") + DirAccess.make_dir_recursive_absolute(_output) + root.size = Vector2i(1280, 720) + root.content_scale_size = Vector2i(1280, 720) + root.msaa_3d = Viewport.MSAA_4X + habitat = ProceduralHabitatPreview.new() + root.add_child(habitat) + habitat.character.set_navigation_enabled(false) + habitat.character.visible = false + habitat.world_inventory.skip_machine_roots = true + habitat.world_inventory.labels_visible = false + habitat.camera.set_process(false) + habitat.camera.set_physics_process(false) + habitat.camera.fov = 49.0 + habitat.focus_production() + machinery = MachineryPreview.new() + habitat.add_child(machinery) + for child in habitat.get_children(): + if child is WorldEnvironment: + child.environment.background_color = Color("#101923") + child.environment.ambient_light_energy = 0.38 + if child is DirectionalLight3D: + child.light_energy = 0.95 + child.rotation = Vector3(-1.1, -0.5, 0) + if child is CanvasLayer: child.visible = false + _make_captions() + _update_scene() + # Start recording only after native scene setup, for reproducible trimming. + print("TEASER_START_FRAME %d" % Engine.get_frames_drawn()) + for frame in range(FRAMES): + _action(frame) + step(2) # Canonical 60 Hz ticks for each 30 fps recorded frame. + _update_scene() + _shot(frame) + await RenderingServer.frame_post_draw + if frame in [60, 240, 450, 660]: + check(root.get_texture().get_image().save_png(_output.path_join("frame-%03d.png" % frame)) == OK, "native keyframe") + await process_frame + _verify_actions() + print("TEASER_END_FRAME %d; failures %d" % [Engine.get_frames_drawn(), failures]) + habitat.free() + world.free() + quit(1 if failures else 0) + + +func _prepare_actions() -> void: + var workshop: Dictionary = production.capture_snapshot().value["machinery"] + for id in workshop["mounts"]: + match workshop["parts"][id]["kind"]: + "extractor": + _extractor = workshop["mounts"][id] + _extractor_head = id + "refinery": + _refinery = workshop["mounts"][id] + _refinery_head = id + "printhead": _printer = workshop["mounts"][id] + check(not _extractor.is_empty() and not _printer.is_empty(), "actual modular workshop available") + # Reclaim the unused disconnected frame, retaining its working refinery head. + move([-300, 0, -1100]) + command("machine_pack", {"machine_id": _refinery}) + for id in workshop["mounts"]: + if workshop["mounts"][id] != _refinery: continue + command("machine_unfit", {"machine_id": _refinery, "item_id": id, "socket_id": ""}) + if id != _refinery_head: command("machine_recycle", {"item_id": id}) + command("machine_recycle", {"item_id": _refinery}) + move([0, 0, 0]) + command("process", {"recipe_id": "craft.plate"}) + step(120) + command("materialize_plate", {}) + var plates: Array = production.observation(context).value["components"] + check(not plates.is_empty(), "one physical plate crafted from remaining finite stock") + if not plates.is_empty(): _plate = plates.back() + move([-2000, 0, 3500]) + + +func _action(frame: int) -> void: + match frame: + 40: + command("wall_build", {"item_id": _plate, "position": [-2000, 0, 2500], "quarter_turn": 0}) + if not production.capture_snapshot().value["walls"].is_empty(): _wall = production.capture_snapshot().value["walls"].keys().back() + 120: + command("wall_remove", {"wall_id": _wall}) + var plates: Array = production.observation(context).value["components"] + if not plates.is_empty(): _plate = plates.back() + 150: + command("wall_build", {"item_id": _plate, "position": [-2000, 0, 2500], "quarter_turn": 1}) + if not production.capture_snapshot().value["walls"].is_empty(): _wall = production.capture_snapshot().value["walls"].keys().back() + 210: + move([0, 0, 3500]) + move([900, 0, 0]) + command("machine_pack", {"machine_id": _printer}) + 270: + move([2500, 0, 0]) + command("machine_deploy", {"machine_id": _printer, "position": [2500, 0, -1000], "mode": "surface"}) + 330: + command("machine_pack", {"machine_id": _printer}) + move([900, 0, 0]) + command("machine_deploy", {"machine_id": _printer, "position": [900, 0, -1000], "mode": "surface"}) + 370: + move([-6000, 0, -1000]) + command("machine_unfit", {"machine_id": _extractor, "item_id": _extractor_head, "socket_id": ""}) + 420: + fit(_extractor, _refinery_head) + 450: + command("machine_run", {"machine_id": _extractor, "recipe": "refine.ice"}) + 580: + move([-4000, 0, 3500]) + move([-2000, 0, 3500]) + command("wall_remove", {"wall_id": _wall}) + var plates: Array = production.observation(context).value["components"] + if not plates.is_empty(): _plate = plates.back() + 630: + move([-3500, 0, 3500]) + command("wall_build", {"item_id": _plate, "position": [-3500, 0, 2500], "quarter_turn": 0}) + + +func _verify_actions() -> void: + check(production.capture_snapshot().value["cargo"]["resource-canister-1"]["water"] == 1000000, "replacement refinery processes actual ice into water") + check(production.capture_snapshot().value["walls"].size() == 1, "one recovered plate rebuilds exactly one wall") + check(production.validate_snapshot_record(production.capture_snapshot().value, runner.tick_number).is_success, "recorded finite world still validates") + print("Visible workshop choreography: %d failures" % failures) + + +func _update_scene() -> void: + var observed: Dictionary = production.observation(context).value + habitat.production.observe(observed) + habitat.production.set_labels_visible(false) + machinery.observe(observed) + habitat.world_inventory.observe(inventory.world_inventory_observation(context).value) + _quiet(habitat) + + +func _quiet(node: Node) -> void: + if node is Label3D: node.visible = false + for child in node.get_children(): _quiet(child) + + +func _make_captions() -> void: + var layer := CanvasLayer.new() + root.add_child(layer) + var header := ColorRect.new() + header.size = Vector2(1280, 48) + header.color = Color(0.035, 0.055, 0.075, 0.9) + layer.add_child(header) + var footer := ColorRect.new() + footer.position = Vector2(0, 634) + footer.size = Vector2(1280, 86) + footer.color = Color(0.035, 0.055, 0.075, 0.91) + layer.add_child(footer) + var brand := Label.new() + brand.position = Vector2(24, 10) + brand.text = "FRONTIER SYSTEMS" + brand.add_theme_font_size_override("font_size", 20) + layer.add_child(brand) + var status := Label.new() + status.position = Vector2(1050, 14) + status.text = "SOURCE PROTOTYPE" + status.add_theme_font_size_override("font_size", 12) + status.modulate = Color("#a3d6d4") + layer.add_child(status) + title = Label.new() + title.position = Vector2(24, 642) + title.add_theme_font_size_override("font_size", 25) + layer.add_child(title) + detail = Label.new() + detail.position = Vector2(24, 676) + detail.add_theme_font_size_override("font_size", 17) + detail.modulate = Color("#b5c7d2") + layer.add_child(detail) + var note := Label.new() + note.position = Vector2(845, 703) + note.text = "Godot prototype ยท Automated commands" + note.add_theme_font_size_override("font_size", 12) + note.modulate = Color("#80949f") + layer.add_child(note) + + +func _shot(frame: int) -> void: + var segment: int = frame / 180 + var local_frame: int = frame % 180 + var starts: Array[Vector3] = [Vector3(-4.4, 3.2, 1), Vector3(3.8, 3.3, 1), Vector3(-8.4, 2.3, 0.7), Vector3(-5, 4, 0.3)] + var targets: Array[Vector3] = [Vector3(-2, 0.9, 2.5), Vector3(1.4, 0.7, -1), Vector3(-6, 0.8, -2), Vector3(-2.7, 0.9, 2.5)] + # Deliberate cuts separate activities; each shot settles on its subject before + # the command fires. Ease a small dolly rather than orbiting away from it. + var travel: float = smoothstep(0.0, 179.0, float(local_frame)) + var target: Vector3 = targets[segment] + if segment == 3: + # The plate is removed at 580 and rebuilt at 630. Follow its destination + # during that empty interval, then hold on the rebuilt wall. + var relocation: float = smoothstep(580.0, 620.0, float(frame)) + target = Vector3(-2, 0.9, 2.5).lerp(Vector3(-3.5, 0.9, 2.5), relocation) + var display_origin: Vector3 = ProceduralWorldInventoryPreview.DISPLAY_ORIGIN + var camera_start: Vector3 = starts[segment] + habitat.camera.position = display_origin + camera_start.lerp(target + (camera_start - target) * 0.94, travel) + habitat.camera.look_at(display_origin + target) + match segment: + 0: + title.text = "A physical plate becomes a wall" + detail.text = "Build it, dismantle it, then turn the recovered plate" + 1: + title.text = "Pack the printer and move it" + detail.text = "The whole fitted machine keeps its components" + 2: + title.text = "Swap the working head" + var water: int = int(production.capture_snapshot().value["cargo"]["resource-canister-1"]["water"]) + detail.text = "Extractor to refinery. Stored water: %.2f kg" % (float(water) / 1000000.0) + 3: + title.text = "Recover the material and build again" + detail.text = "The same plate, in a different place" diff --git a/tools/godot/capture_telegram_teaser.gd.uid b/tools/godot/capture_telegram_teaser.gd.uid new file mode 100644 index 00000000..e38378c3 --- /dev/null +++ b/tools/godot/capture_telegram_teaser.gd.uid @@ -0,0 +1 @@ +uid://muqcy2l6dawg