ace-demo 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/demo/config.yml +3 -0
  3. data/.ace-defaults/demo/tapes/hello.tape +12 -0
  4. data/.ace-defaults/nav/protocols/wfi-sources/ace-demo.yml +19 -0
  5. data/CHANGELOG.md +310 -0
  6. data/LICENSE +21 -0
  7. data/README.md +69 -0
  8. data/Rakefile +16 -0
  9. data/exe/ace-demo +14 -0
  10. data/handbook/skills/as-demo-create/SKILL.md +27 -0
  11. data/handbook/skills/as-demo-record/SKILL.md +27 -0
  12. data/handbook/workflow-instructions/demo/create.wf.md +89 -0
  13. data/handbook/workflow-instructions/demo/record.wf.md +146 -0
  14. data/lib/ace/demo/atoms/attach_output_printer.rb +22 -0
  15. data/lib/ace/demo/atoms/demo_comment_formatter.rb +25 -0
  16. data/lib/ace/demo/atoms/demo_name_sanitizer.rb +21 -0
  17. data/lib/ace/demo/atoms/demo_yaml_parser.rb +148 -0
  18. data/lib/ace/demo/atoms/playback_speed_parser.rb +30 -0
  19. data/lib/ace/demo/atoms/tape_content_generator.rb +37 -0
  20. data/lib/ace/demo/atoms/tape_metadata_parser.rb +42 -0
  21. data/lib/ace/demo/atoms/tape_search_dirs.rb +19 -0
  22. data/lib/ace/demo/atoms/vhs_command_builder.rb +15 -0
  23. data/lib/ace/demo/atoms/vhs_tape_compiler.rb +38 -0
  24. data/lib/ace/demo/cli/commands/attach.rb +33 -0
  25. data/lib/ace/demo/cli/commands/create.rb +69 -0
  26. data/lib/ace/demo/cli/commands/list.rb +35 -0
  27. data/lib/ace/demo/cli/commands/record.rb +214 -0
  28. data/lib/ace/demo/cli/commands/retime.rb +46 -0
  29. data/lib/ace/demo/cli/commands/show.rb +72 -0
  30. data/lib/ace/demo/cli.rb +70 -0
  31. data/lib/ace/demo/models/execution_result.rb +22 -0
  32. data/lib/ace/demo/molecules/demo_comment_poster.rb +54 -0
  33. data/lib/ace/demo/molecules/demo_sandbox_builder.rb +121 -0
  34. data/lib/ace/demo/molecules/demo_teardown_executor.rb +48 -0
  35. data/lib/ace/demo/molecules/gh_asset_uploader.rb +101 -0
  36. data/lib/ace/demo/molecules/inline_recorder.rb +62 -0
  37. data/lib/ace/demo/molecules/media_retimer.rb +81 -0
  38. data/lib/ace/demo/molecules/tape_resolver.rb +51 -0
  39. data/lib/ace/demo/molecules/tape_scanner.rb +148 -0
  40. data/lib/ace/demo/molecules/tape_writer.rb +34 -0
  41. data/lib/ace/demo/molecules/vhs_executor.rb +38 -0
  42. data/lib/ace/demo/organisms/demo_attacher.rb +44 -0
  43. data/lib/ace/demo/organisms/demo_recorder.rb +95 -0
  44. data/lib/ace/demo/organisms/tape_creator.rb +68 -0
  45. data/lib/ace/demo/version.rb +7 -0
  46. data/lib/ace/demo.rb +84 -0
  47. metadata +204 -0
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+
7
+ module Ace
8
+ module Demo
9
+ module Molecules
10
+ class GhAssetUploader
11
+ RELEASE_TAG = "demo-assets"
12
+ RELEASE_TITLE = "Demo Assets"
13
+ RELEASE_NOTES = "Auto-generated release for demo GIF hosting"
14
+
15
+ def initialize(now: -> { Time.now.to_i }, gh_bin: "gh")
16
+ @now = now
17
+ @gh_bin = gh_bin
18
+ end
19
+
20
+ def upload(file_path:, dry_run: false)
21
+ raise ArgumentError, "Recording file not found: #{file_path}" unless File.exist?(file_path)
22
+
23
+ asset_name = stamped_asset_name(file_path)
24
+ if dry_run
25
+ repo = ENV.fetch("GITHUB_REPOSITORY", "OWNER/REPO")
26
+ asset_url = "https://github.com/#{repo}/releases/download/#{RELEASE_TAG}/#{asset_name}"
27
+ return {asset_name: asset_name, asset_url: asset_url, dry_run: true}
28
+ end
29
+
30
+ repo = repo_name_with_owner
31
+ asset_url = "https://github.com/#{repo}/releases/download/#{RELEASE_TAG}/#{asset_name}"
32
+
33
+ ensure_release_exists!
34
+
35
+ Dir.mktmpdir("ace_demo_attach") do |tmpdir|
36
+ upload_path = File.join(tmpdir, asset_name)
37
+ FileUtils.cp(file_path, upload_path)
38
+ run_gh!("release", "upload", RELEASE_TAG, upload_path, "--clobber")
39
+ end
40
+
41
+ {asset_name: asset_name, asset_url: asset_url, dry_run: false}
42
+ end
43
+
44
+ private
45
+
46
+ def stamped_asset_name(file_path)
47
+ ext = File.extname(file_path)
48
+ base = File.basename(file_path, ext)
49
+ "#{base}-#{@now.call}#{ext}"
50
+ end
51
+
52
+ def repo_name_with_owner
53
+ stdout, stderr, status = Open3.capture3(@gh_bin, "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner")
54
+ raise_auth_if_needed!(stderr)
55
+ raise GhCommandError, "Failed to detect repository: #{stderr.strip}" unless status.success?
56
+
57
+ repo = stdout.strip
58
+ raise GhCommandError, "Failed to detect repository" if repo.empty?
59
+
60
+ repo
61
+ end
62
+
63
+ def ensure_release_exists!
64
+ _stdout, stderr, status = Open3.capture3(@gh_bin, "release", "view", RELEASE_TAG)
65
+ raise_auth_if_needed!(stderr)
66
+ return if status.success?
67
+
68
+ return create_release! if stderr.downcase.include?("not found") || stderr.downcase.include?("could not find")
69
+
70
+ raise GhUploadError, "Failed to check release '#{RELEASE_TAG}': #{stderr.strip}"
71
+ end
72
+
73
+ def create_release!
74
+ run_gh!("release", "create", RELEASE_TAG, "--title", RELEASE_TITLE, "--notes", RELEASE_NOTES)
75
+ end
76
+
77
+ def run_gh!(*args)
78
+ _stdout, stderr, status = Open3.capture3(@gh_bin, *args)
79
+ raise_auth_if_needed!(stderr)
80
+ return if status.success?
81
+
82
+ raise GhUploadError, "gh #{args.join(" ")} failed: #{stderr.strip}"
83
+ end
84
+
85
+ def raise_auth_if_needed!(stderr)
86
+ return unless auth_error?(stderr)
87
+
88
+ raise GhAuthenticationError, "gh CLI not authenticated. Run: gh auth login"
89
+ end
90
+
91
+ def auth_error?(stderr)
92
+ text = stderr.to_s.downcase
93
+ text.include?("gh auth login") ||
94
+ text.include?("not logged into any github hosts") ||
95
+ text.include?("authentication required") ||
96
+ text.include?("authentication token")
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ace/b36ts"
5
+ require_relative "../atoms/demo_name_sanitizer"
6
+
7
+ module Ace
8
+ module Demo
9
+ module Molecules
10
+ class InlineRecorder
11
+ def initialize(
12
+ executor: VhsExecutor.new,
13
+ output_dir: Demo.config["output_dir"],
14
+ vhs_bin: Demo.config["vhs_bin"]
15
+ )
16
+ @executor = executor
17
+ @output_dir = output_dir || ".ace-local/demo"
18
+ @vhs_bin = vhs_bin || "vhs"
19
+ end
20
+
21
+ def record(name:, commands:, format: "gif", output: nil, description: nil, tags: nil,
22
+ font_size: 16, width: 960, height: 480, timeout: "2s")
23
+ safe_name = Atoms::DemoNameSanitizer.sanitize(name)
24
+ session_id = generate_session_id
25
+ session_dir = File.expand_path(File.join(@output_dir, session_id), Dir.pwd)
26
+ tape_path = File.join(session_dir, "#{safe_name}.tape")
27
+ output_path = output ? File.expand_path(output, Dir.pwd) : File.join(session_dir, "#{safe_name}.#{format}")
28
+
29
+ content = Atoms::TapeContentGenerator.generate(
30
+ name: safe_name,
31
+ commands: commands,
32
+ description: description,
33
+ tags: tags,
34
+ output_path: "./#{safe_name}.#{format}",
35
+ font_size: font_size,
36
+ width: width,
37
+ height: height,
38
+ timeout: timeout
39
+ )
40
+
41
+ FileUtils.mkdir_p(session_dir)
42
+ File.write(tape_path, content)
43
+
44
+ cmd = Atoms::VhsCommandBuilder.build(
45
+ tape_path: tape_path,
46
+ output_path: output_path,
47
+ vhs_bin: @vhs_bin
48
+ )
49
+ @executor.run(cmd)
50
+
51
+ {output_path: output_path, tape_path: tape_path, session_dir: session_dir}
52
+ end
53
+
54
+ private
55
+
56
+ def generate_session_id
57
+ Ace::B36ts.now
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+ require "fileutils"
6
+
7
+ module Ace
8
+ module Demo
9
+ module Molecules
10
+ class MediaRetimer
11
+ def initialize(ffmpeg_bin: "ffmpeg")
12
+ @ffmpeg_bin = ffmpeg_bin
13
+ end
14
+
15
+ def retime(input_path:, speed:, output_path: nil, dry_run: false)
16
+ raise ArgumentError, "Input file not found: #{input_path}" unless File.exist?(input_path)
17
+
18
+ parsed = Atoms::PlaybackSpeedParser.parse(speed)
19
+ raise ArgumentError, "Playback speed is required." unless parsed
20
+
21
+ target_path = output_path || default_output_path(input_path, parsed[:label])
22
+ return {input_path: input_path, output_path: target_path, speed: parsed[:label], dry_run: true} if dry_run
23
+
24
+ ensure_ffmpeg_available!
25
+ FileUtils.mkdir_p(File.dirname(target_path))
26
+
27
+ cmd = build_ffmpeg_command(
28
+ input_path: input_path,
29
+ output_path: target_path,
30
+ factor: parsed[:factor]
31
+ )
32
+ _stdout, stderr, status = Open3.capture3(*cmd)
33
+ raise MediaRetimeError, "FFmpeg retime failed: #{stderr.strip}" unless status.success?
34
+
35
+ {input_path: input_path, output_path: target_path, speed: parsed[:label], dry_run: false}
36
+ rescue Errno::ENOENT
37
+ raise FfmpegNotFoundError, "FFmpeg not found. Install ffmpeg to use retime."
38
+ end
39
+
40
+ private
41
+
42
+ def ensure_ffmpeg_available!
43
+ _stdout, _stderr, status = Open3.capture3(@ffmpeg_bin, "-version")
44
+ return if status.success?
45
+
46
+ raise FfmpegNotFoundError, "FFmpeg not found. Install ffmpeg to use retime."
47
+ rescue Errno::ENOENT
48
+ raise FfmpegNotFoundError, "FFmpeg not found. Install ffmpeg to use retime."
49
+ end
50
+
51
+ def default_output_path(input_path, speed_label)
52
+ path = Pathname.new(input_path)
53
+ ext = path.extname.downcase
54
+ basename = path.basename(ext).to_s
55
+ File.join(path.dirname.to_s, "#{basename}-#{speed_label}#{ext}")
56
+ end
57
+
58
+ def build_ffmpeg_command(input_path:, output_path:, factor:)
59
+ case File.extname(input_path).downcase
60
+ when ".gif"
61
+ [
62
+ @ffmpeg_bin, "-y", "-i", input_path,
63
+ "-filter_complex",
64
+ "[0:v]setpts=PTS/#{factor},split[v][p];[p]palettegen=stats_mode=full[pal];[v][pal]paletteuse=dither=bayer",
65
+ output_path
66
+ ]
67
+ when ".mp4", ".webm"
68
+ [
69
+ @ffmpeg_bin, "-y", "-i", input_path,
70
+ "-filter:v", "setpts=PTS/#{factor}",
71
+ "-an",
72
+ output_path
73
+ ]
74
+ else
75
+ raise ArgumentError, "Unsupported media format: #{File.extname(input_path)}. Use gif, mp4, or webm."
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Demo
5
+ module Molecules
6
+ class TapeResolver
7
+ def initialize(gem_root: Demo.gem_root, home_dir: Dir.home, cwd: Dir.pwd)
8
+ @gem_root = gem_root
9
+ @home_dir = home_dir
10
+ @cwd = cwd
11
+ end
12
+
13
+ def resolve(tape_ref)
14
+ direct_path = File.expand_path(tape_ref, @cwd)
15
+ return direct_path if File.file?(direct_path)
16
+
17
+ candidates = search_dirs.map do |dir|
18
+ candidate_names(tape_ref).map { |name| File.join(dir, name) }
19
+ end
20
+ candidates.flatten!
21
+
22
+ match = candidates.find { |path| File.file?(path) }
23
+ return match if match
24
+
25
+ raise TapeNotFoundError, missing_message(tape_ref)
26
+ end
27
+
28
+ def search_dirs
29
+ Atoms::TapeSearchDirs.build(cwd: @cwd, home_dir: @home_dir, gem_root: @gem_root)
30
+ end
31
+
32
+ private
33
+
34
+ def candidate_names(tape_ref)
35
+ return [tape_ref] if explicit_filename?(tape_ref)
36
+
37
+ ["#{tape_ref}.tape.yml", "#{tape_ref}.tape.yaml", "#{tape_ref}.tape"]
38
+ end
39
+
40
+ def explicit_filename?(tape_ref)
41
+ tape_ref.end_with?(".tape", ".tape.yml", ".tape.yaml", ".yml", ".yaml")
42
+ end
43
+
44
+ def missing_message(tape_ref)
45
+ searched = search_dirs.join(", ")
46
+ "Tape not found: #{tape_ref}\nSearched: #{searched}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Demo
5
+ module Molecules
6
+ class TapeScanner
7
+ def initialize(gem_root: Demo.gem_root, home_dir: Dir.home, cwd: Dir.pwd, parser: Atoms::TapeMetadataParser)
8
+ @gem_root = gem_root
9
+ @home_dir = home_dir
10
+ @cwd = cwd
11
+ @parser = parser
12
+ end
13
+
14
+ def list
15
+ discovered = {}
16
+
17
+ search_dirs.each do |dir|
18
+ next unless Dir.exist?(dir)
19
+
20
+ discover_paths(dir).each do |path|
21
+ name = logical_name(path)
22
+ next if discovered.key?(name)
23
+
24
+ discovered[name] = build_record(name, path)
25
+ end
26
+ end
27
+
28
+ discovered.keys.sort.map { |name| discovered[name] }
29
+ end
30
+
31
+ def find(tape_ref)
32
+ direct_path = File.expand_path(tape_ref, @cwd)
33
+ if File.exist?(direct_path) && File.file?(direct_path)
34
+ name = logical_name(direct_path)
35
+ return build_record(name, direct_path)
36
+ end
37
+
38
+ lookup_name = logical_name(tape_ref)
39
+ match = find_in_search_dirs(lookup_name, tape_ref)
40
+ return match if match
41
+
42
+ raise TapeNotFoundError, missing_message(tape_ref)
43
+ end
44
+
45
+ private
46
+
47
+ def search_dirs
48
+ Atoms::TapeSearchDirs.build(cwd: @cwd, home_dir: @home_dir, gem_root: @gem_root)
49
+ end
50
+
51
+ def discover_paths(dir)
52
+ patterns = [File.join(dir, "*.tape.yml"), File.join(dir, "*.tape.yaml"), File.join(dir, "*.tape")]
53
+ patterns.flat_map { |pattern| Dir.glob(pattern).sort }
54
+ end
55
+
56
+ def build_record(name, path)
57
+ content = File.read(path)
58
+ format = path.end_with?(".tape.yml", ".tape.yaml") ? "yaml" : "tape"
59
+ metadata = extract_metadata(path: path, content: content, format: format)
60
+
61
+ {
62
+ name: name,
63
+ path: path,
64
+ display_path: display_path(path),
65
+ source: "#{display_path(File.dirname(path))}/",
66
+ format: format,
67
+ metadata: metadata,
68
+ description: metadata["description"],
69
+ content: content
70
+ }
71
+ end
72
+
73
+ def extract_metadata(path:, content:, format:)
74
+ return @parser.parse(content) if format == "tape"
75
+
76
+ spec = Atoms::DemoYamlParser.parse_file(path)
77
+ {
78
+ "description" => spec["description"],
79
+ "tags" => spec["tags"],
80
+ "settings" => spec["settings"],
81
+ "scene_names" => spec.fetch("scenes", []).map { |scene| scene["name"] }.compact
82
+ }
83
+ rescue DemoYamlParseError => e
84
+ {"description" => nil, "parse_error" => e.message}
85
+ end
86
+
87
+ def find_in_search_dirs(name, tape_ref)
88
+ explicit = explicit_filename?(tape_ref)
89
+ candidates = if explicit
90
+ [tape_ref]
91
+ else
92
+ ["#{name}.tape.yml", "#{name}.tape.yaml", "#{name}.tape"]
93
+ end
94
+
95
+ search_dirs.each do |dir|
96
+ candidates.each do |filename|
97
+ path = File.join(dir, filename)
98
+ next unless File.file?(path)
99
+
100
+ return build_record(logical_name(path), path)
101
+ end
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ def explicit_filename?(tape_ref)
108
+ tape_ref.end_with?(".tape", ".tape.yml", ".tape.yaml", ".yml", ".yaml")
109
+ end
110
+
111
+ def logical_name(path)
112
+ File.basename(path).sub(/\.tape\.ya?ml\z/, "").sub(/\.tape\z/, "").sub(/\.ya?ml\z/, "")
113
+ end
114
+
115
+ def display_path(path)
116
+ expanded = File.expand_path(path)
117
+
118
+ if inside?(expanded, @cwd)
119
+ relative_to(@cwd, expanded)
120
+ elsif inside?(expanded, @gem_root)
121
+ relative_to(@gem_root, expanded)
122
+ elsif inside?(expanded, @home_dir)
123
+ relative_to(@home_dir, expanded, "~")
124
+ else
125
+ expanded
126
+ end
127
+ end
128
+
129
+ def relative_to(base, path, prefix = nil)
130
+ suffix = path.delete_prefix("#{File.expand_path(base)}/")
131
+ return suffix if prefix.nil?
132
+
133
+ [prefix, suffix].join("/")
134
+ end
135
+
136
+ def inside?(path, base)
137
+ path == File.expand_path(base) || path.start_with?("#{File.expand_path(base)}/")
138
+ end
139
+
140
+ def missing_message(tape_ref)
141
+ names = list.map { |item| item[:name] }
142
+ available = names.empty? ? "(none)" : names.join(", ")
143
+ "Tape not found: #{tape_ref}\nAvailable tapes: #{available}"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Demo
7
+ module Molecules
8
+ class TapeWriter
9
+ def initialize(cwd: Dir.pwd)
10
+ @cwd = cwd
11
+ end
12
+
13
+ def write(name:, content:, force: false, extension: ".tape")
14
+ path = tape_path(name, extension: extension)
15
+
16
+ if File.exist?(path) && !force
17
+ raise TapeAlreadyExistsError, "Tape already exists: #{path}\nUse --force to overwrite."
18
+ end
19
+
20
+ FileUtils.mkdir_p(File.dirname(path))
21
+ File.write(path, content)
22
+
23
+ path
24
+ end
25
+
26
+ private
27
+
28
+ def tape_path(name, extension:)
29
+ File.join(@cwd, ".ace", "demo", "tapes", "#{name}#{extension}")
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Demo
7
+ module Molecules
8
+ class VhsExecutor
9
+ INSTALL_URL = "https://github.com/charmbracelet/vhs"
10
+
11
+ def vhs_available?(vhs_bin: "vhs")
12
+ _stdout, _stderr, status = Open3.capture3(vhs_bin, "--version")
13
+ status.success?
14
+ rescue Errno::ENOENT
15
+ false
16
+ end
17
+
18
+ def run(cmd, vhs_bin: "vhs", chdir: nil)
19
+ options = {}
20
+ options[:chdir] = chdir if chdir
21
+ stdout, stderr, status = Open3.capture3(*cmd, **options)
22
+ result = Models::ExecutionResult.new(
23
+ stdout: stdout.strip,
24
+ stderr: stderr.strip,
25
+ success: status.success?,
26
+ exit_code: status.exitstatus
27
+ )
28
+
29
+ return result if result.success?
30
+
31
+ raise VhsExecutionError, "VHS execution failed: #{result.stderr}"
32
+ rescue Errno::ENOENT
33
+ raise VhsNotFoundError, "VHS not found. Install: #{INSTALL_URL}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Demo
5
+ module Organisms
6
+ class DemoAttacher
7
+ def initialize(uploader: Molecules::GhAssetUploader.new,
8
+ formatter: Atoms::DemoCommentFormatter,
9
+ poster: Molecules::DemoCommentPoster.new,
10
+ clock: -> { Time.now })
11
+ @uploader = uploader
12
+ @formatter = formatter
13
+ @poster = poster
14
+ @clock = clock
15
+ end
16
+
17
+ def attach(file:, pr:, dry_run: false)
18
+ raise ArgumentError, "Recording file not found: #{file}" unless File.exist?(file)
19
+
20
+ demo_name = File.basename(file, File.extname(file))
21
+ ext = File.extname(file).delete_prefix(".").downcase
22
+ upload = @uploader.upload(file_path: file, dry_run: dry_run)
23
+ comment_body = @formatter.format(
24
+ demo_name: demo_name,
25
+ asset_url: upload.fetch(:asset_url),
26
+ recorded_at: @clock.call,
27
+ format: ext
28
+ )
29
+
30
+ @poster.post(pr: pr, comment_body: comment_body, dry_run: dry_run)
31
+
32
+ {
33
+ dry_run: dry_run,
34
+ pr: pr,
35
+ demo_name: demo_name,
36
+ asset_name: upload.fetch(:asset_name),
37
+ asset_url: upload.fetch(:asset_url),
38
+ comment_body: comment_body
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Demo
7
+ module Organisms
8
+ class DemoRecorder
9
+ SUPPORTED_FORMATS = %w[gif mp4 webm].freeze
10
+
11
+ def initialize(
12
+ resolver: Molecules::TapeResolver.new,
13
+ executor: Molecules::VhsExecutor.new,
14
+ yaml_parser: Atoms::DemoYamlParser,
15
+ yaml_compiler: Atoms::VhsTapeCompiler,
16
+ sandbox_builder: Molecules::DemoSandboxBuilder.new,
17
+ teardown_executor: Molecules::DemoTeardownExecutor.new,
18
+ output_dir: Demo.config["output_dir"],
19
+ vhs_bin: Demo.config["vhs_bin"]
20
+ )
21
+ @resolver = resolver
22
+ @executor = executor
23
+ @yaml_parser = yaml_parser
24
+ @yaml_compiler = yaml_compiler
25
+ @sandbox_builder = sandbox_builder
26
+ @teardown_executor = teardown_executor
27
+ @output_dir = output_dir || ".ace-local/demo"
28
+ @vhs_bin = vhs_bin || "vhs"
29
+ end
30
+
31
+ def record(tape_ref:, output: nil, format: nil)
32
+ normalized_format = format&.to_s&.downcase
33
+ if normalized_format && !SUPPORTED_FORMATS.include?(normalized_format)
34
+ raise ArgumentError, "Unsupported format: #{normalized_format}"
35
+ end
36
+
37
+ tape_path = @resolver.resolve(tape_ref)
38
+ return record_yaml_tape(tape_path: tape_path, output: output, format: normalized_format) if yaml_tape?(tape_path)
39
+
40
+ record_tape_file(tape_path: tape_path, output: output, format: normalized_format || "gif")
41
+ end
42
+
43
+ private
44
+
45
+ def record_tape_file(tape_path:, output:, format:)
46
+ output_path = File.expand_path(output || default_output_path(tape_path, format), Dir.pwd)
47
+ FileUtils.mkdir_p(File.dirname(output_path))
48
+
49
+ cmd = Atoms::VhsCommandBuilder.build(tape_path: tape_path, output_path: output_path, vhs_bin: @vhs_bin)
50
+ @executor.run(cmd)
51
+ output_path
52
+ end
53
+
54
+ def record_yaml_tape(tape_path:, output:, format:)
55
+ spec = @yaml_parser.parse_file(tape_path)
56
+ selected_format = (format || spec.dig("settings", "format") || "gif").to_s.downcase
57
+ raise ArgumentError, "Unsupported format: #{selected_format}" unless SUPPORTED_FORMATS.include?(selected_format)
58
+
59
+ output_path = File.expand_path(output || default_output_path(tape_path, selected_format), Dir.pwd)
60
+ FileUtils.mkdir_p(File.dirname(output_path))
61
+
62
+ sandbox = @sandbox_builder.build(source_tape_path: tape_path, setup_steps: spec["setup"] || [])
63
+ begin
64
+ compiled_tape_path = File.join(
65
+ sandbox[:path],
66
+ "#{File.basename(tape_path).sub(/\.ya?ml\z/, "")}.compiled.tape"
67
+ )
68
+ tape_output = "./#{File.basename(output_path)}"
69
+ tape_content = @yaml_compiler.compile(spec: spec, output_path: tape_output)
70
+ File.write(compiled_tape_path, tape_content)
71
+
72
+ cmd = Atoms::VhsCommandBuilder.build(
73
+ tape_path: compiled_tape_path,
74
+ output_path: output_path,
75
+ vhs_bin: @vhs_bin
76
+ )
77
+ @executor.run(cmd, chdir: sandbox[:path])
78
+ output_path
79
+ ensure
80
+ @teardown_executor.execute(steps: spec["teardown"] || [], sandbox_path: sandbox[:path]) if sandbox
81
+ end
82
+ end
83
+
84
+ def default_output_path(tape_ref, format)
85
+ basename = File.basename(tape_ref).sub(/\.tape\.ya?ml\z/, "").sub(/\.tape\z/, "").sub(/\.ya?ml\z/, "")
86
+ File.expand_path(File.join(@output_dir, "#{basename}.#{format}"), Dir.pwd)
87
+ end
88
+
89
+ def yaml_tape?(path)
90
+ path.end_with?(".tape.yml", ".tape.yaml")
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end