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.
- checksums.yaml +7 -0
- data/.ace-defaults/demo/config.yml +3 -0
- data/.ace-defaults/demo/tapes/hello.tape +12 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-demo.yml +19 -0
- data/CHANGELOG.md +310 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/Rakefile +16 -0
- data/exe/ace-demo +14 -0
- data/handbook/skills/as-demo-create/SKILL.md +27 -0
- data/handbook/skills/as-demo-record/SKILL.md +27 -0
- data/handbook/workflow-instructions/demo/create.wf.md +89 -0
- data/handbook/workflow-instructions/demo/record.wf.md +146 -0
- data/lib/ace/demo/atoms/attach_output_printer.rb +22 -0
- data/lib/ace/demo/atoms/demo_comment_formatter.rb +25 -0
- data/lib/ace/demo/atoms/demo_name_sanitizer.rb +21 -0
- data/lib/ace/demo/atoms/demo_yaml_parser.rb +148 -0
- data/lib/ace/demo/atoms/playback_speed_parser.rb +30 -0
- data/lib/ace/demo/atoms/tape_content_generator.rb +37 -0
- data/lib/ace/demo/atoms/tape_metadata_parser.rb +42 -0
- data/lib/ace/demo/atoms/tape_search_dirs.rb +19 -0
- data/lib/ace/demo/atoms/vhs_command_builder.rb +15 -0
- data/lib/ace/demo/atoms/vhs_tape_compiler.rb +38 -0
- data/lib/ace/demo/cli/commands/attach.rb +33 -0
- data/lib/ace/demo/cli/commands/create.rb +69 -0
- data/lib/ace/demo/cli/commands/list.rb +35 -0
- data/lib/ace/demo/cli/commands/record.rb +214 -0
- data/lib/ace/demo/cli/commands/retime.rb +46 -0
- data/lib/ace/demo/cli/commands/show.rb +72 -0
- data/lib/ace/demo/cli.rb +70 -0
- data/lib/ace/demo/models/execution_result.rb +22 -0
- data/lib/ace/demo/molecules/demo_comment_poster.rb +54 -0
- data/lib/ace/demo/molecules/demo_sandbox_builder.rb +121 -0
- data/lib/ace/demo/molecules/demo_teardown_executor.rb +48 -0
- data/lib/ace/demo/molecules/gh_asset_uploader.rb +101 -0
- data/lib/ace/demo/molecules/inline_recorder.rb +62 -0
- data/lib/ace/demo/molecules/media_retimer.rb +81 -0
- data/lib/ace/demo/molecules/tape_resolver.rb +51 -0
- data/lib/ace/demo/molecules/tape_scanner.rb +148 -0
- data/lib/ace/demo/molecules/tape_writer.rb +34 -0
- data/lib/ace/demo/molecules/vhs_executor.rb +38 -0
- data/lib/ace/demo/organisms/demo_attacher.rb +44 -0
- data/lib/ace/demo/organisms/demo_recorder.rb +95 -0
- data/lib/ace/demo/organisms/tape_creator.rb +68 -0
- data/lib/ace/demo/version.rb +7 -0
- data/lib/ace/demo.rb +84 -0
- 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
|