| 1 | // Copyright (c) 2021 Lars Pontoppidan. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | // |
| 5 | // vgret (V Graphics REgression Tool) aids in generating screenshots of various graphical `gg` |
| 6 | // based V applications, in a structured directory hierarchy, with the intent of either: |
| 7 | // * Generate a directory structure of screenshots/images to test against |
| 8 | // (which, as an example, could later be pushed to a remote git repository) |
| 9 | // * Test for *visual* differences between two, structurally equal, directories |
| 10 | // |
| 11 | // vgret uses features and applications that is currently only available on Linux based distros: |
| 12 | // idiff - to programmatically find *visual* differences between two images: |
| 13 | // - Ubuntu: `sudo apt install openimageio-tools` |
| 14 | // - Arch: `sudo pacman -S openimageio` |
| 15 | // Xvfb - to start a virtual X server framebuffer: |
| 16 | // - Ubuntu: `sudo apt install xvfb` |
| 17 | // - Arch: `sudo pacman -S xorg-server-xvfb` |
| 18 | // |
| 19 | // For developers: |
| 20 | // For a quick overview of the generated images you can use `montage` from imagemagick to generate a "Contact Sheet": |
| 21 | // montage -verbose -label '%f' -font Helvetica -pointsize 10 -background '#000000' -fill 'gray' -define jpeg:size=200x200 -geometry 200x200+2+2 -auto-orient $(fd -t f . /path/to/vgret/out/dir) /tmp/montage.jpg |
| 22 | // - Ubuntu: `sudo apt install imagemagick` |
| 23 | // - Arch: `sudo pacman -S imagemagick` |
| 24 | // |
| 25 | // To generate the reference images locally - or for uploading to a remote repo like `gg-regression-images` |
| 26 | // You can do the following: |
| 27 | // 1. `export DISPLAY=:99` # Start all graphical apps on DISPLAY 99 |
| 28 | // 2. `Xvfb $DISPLAY -screen 0 1280x1024x24 &` # Starts a virtual X11 screen buffer |
| 29 | // 3. `v gret -v /tmp/gg-regression-images` # Generate reference images to /tmp/gg-regression-images |
| 30 | // 4. `v gret -v /tmp/test /tmp/gg-regression-images` # Test if the tests can pass locally by comparing to a fresh imageset |
| 31 | // 5. Visually check the images (you can get an overview by running the `montage` command above) |
| 32 | // 6. Upload to GitHub or keep locally for more testing/tweaking |
| 33 | // |
| 34 | // It's a known factor that the images generated on a local machine won't match the images generated on a remote machine by 100%. |
| 35 | // They will most likely differ by a small percentage - the comparison tool can be tweaked to accept these subtle changes, |
| 36 | // at the expense of slightly more inaccurate test results. For non-animated apps the percentage should be > 0.01. |
| 37 | // You can emulate or test these inaccuracies to some extend locally by simply running the test from a terminal using |
| 38 | // your physical X11 session display (Usually DISPLAY=:0). |
| 39 | // |
| 40 | // Read more about the options of `idiff` here: https://openimageio.readthedocs.io/en/latest/idiff.html |
| 41 | // |
| 42 | import os |
| 43 | import flag |
| 44 | import time |
| 45 | import toml |
| 46 | |
| 47 | const tool_name = 'vgret' |
| 48 | const tool_version = '0.0.2' |
| 49 | const tool_description = '\n Dump and/or compare rendered frames of graphical apps |
| 50 | both external and `gg` based apps is supported. |
| 51 | |
| 52 | Examples: |
| 53 | Generate screenshots to `/tmp/test` |
| 54 | v gret /tmp/test |
| 55 | Generate and compare screenshots in `/tmp/src` to existing screenshots in `/tmp/dst` |
| 56 | v gret /tmp/src /tmp/dst |
| 57 | Compare screenshots in `/tmp/src` to existing screenshots in `/tmp/dst` |
| 58 | v gret --compare-only /tmp/src /tmp/dst |
| 59 | ' |
| 60 | |
| 61 | const tmp_dir = os.join_path(os.vtmp_dir(), tool_name) |
| 62 | const runtime_os = os.user_os() |
| 63 | const v_root = os.real_path(@VMODROOT) |
| 64 | |
| 65 | const supported_hosts = ['linux'] |
| 66 | const supported_capture_methods = ['gg_record', 'generic_screenshot'] |
| 67 | // External tool executables |
| 68 | const v_exe = os.getenv('VEXE') |
| 69 | const idiff_exe = os.find_abs_path_of_executable('idiff') or { '' } |
| 70 | |
| 71 | const embedded_toml = $embed_file('vgret.defaults.toml', .zlib) |
| 72 | const default_toml = embedded_toml.to_string() |
| 73 | const empty_toml_array = []toml.Any{} |
| 74 | const empty_toml_map = map[string]toml.Any{} |
| 75 | |
| 76 | struct Config { |
| 77 | path string |
| 78 | mut: |
| 79 | apps []AppConfig |
| 80 | } |
| 81 | |
| 82 | struct CompareOptions { |
| 83 | mut: |
| 84 | method string = 'idiff' |
| 85 | flags []string |
| 86 | } |
| 87 | |
| 88 | struct CaptureRegion { |
| 89 | mut: |
| 90 | x int |
| 91 | y int |
| 92 | width int |
| 93 | height int |
| 94 | } |
| 95 | |
| 96 | fn (cr CaptureRegion) is_empty() bool { |
| 97 | return cr.width == 0 && cr.height == 0 |
| 98 | } |
| 99 | |
| 100 | struct CaptureOptions { |
| 101 | mut: |
| 102 | method string = 'gg_record' |
| 103 | wait_ms int // used by "generic_screenshot" to wait X milliseconds *after* execution of the app |
| 104 | flags []string |
| 105 | regions []CaptureRegion |
| 106 | env map[string]string |
| 107 | } |
| 108 | |
| 109 | fn (co CaptureOptions) validate() ! { |
| 110 | if co.method !in supported_capture_methods { |
| 111 | return error('capture method "${co.method}" is not supported. Supported methods are: ${supported_capture_methods}') |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | struct AppConfig { |
| 116 | compare CompareOptions |
| 117 | capture CaptureOptions |
| 118 | path string |
| 119 | abs_path string |
| 120 | mut: |
| 121 | screenshots_path string |
| 122 | screenshots []string |
| 123 | } |
| 124 | |
| 125 | struct Options { |
| 126 | verbose bool |
| 127 | compare_only bool |
| 128 | root_path string |
| 129 | mut: |
| 130 | config Config |
| 131 | } |
| 132 | |
| 133 | fn (opt Options) verbose_execute(cmd string) os.Result { |
| 134 | opt.verbose_eprintln('Running `${cmd}`') |
| 135 | return os.execute(cmd) |
| 136 | } |
| 137 | |
| 138 | fn (opt Options) verbose_eprintln(msg string) { |
| 139 | if opt.verbose { |
| 140 | eprintln(msg) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | fn main() { |
| 145 | if runtime_os !in supported_hosts { |
| 146 | eprintln('${tool_name} is currently only supported on ${supported_hosts} hosts') |
| 147 | exit(1) |
| 148 | } |
| 149 | if os.args.len == 1 { |
| 150 | eprintln('Usage: ${tool_name} PATH \n${tool_description}\n${tool_name} -h for more help...') |
| 151 | exit(1) |
| 152 | } |
| 153 | |
| 154 | mut fp := flag.new_flag_parser(os.args[1..]) |
| 155 | fp.application(tool_name) |
| 156 | fp.version(tool_version) |
| 157 | fp.description(tool_description) |
| 158 | fp.arguments_description('PATH [PATH]') |
| 159 | fp.skip_executable() |
| 160 | |
| 161 | show_help := fp.bool('help', `h`, false, 'Show this help text.') |
| 162 | |
| 163 | // Collect tool options |
| 164 | mut opt := Options{ |
| 165 | verbose: fp.bool('verbose', `v`, false, "Be verbose about the tool's progress.") |
| 166 | compare_only: fp.bool('compare-only', `c`, false, |
| 167 | "Don't generate screenshots - only compare input directories") |
| 168 | root_path: fp.string('root-path', `r`, v_root, 'Root path of the comparison') |
| 169 | } |
| 170 | |
| 171 | toml_conf := fp.string('toml-config', `t`, default_toml, |
| 172 | 'Path or string with TOML configuration') |
| 173 | arg_paths := fp.finalize()! |
| 174 | if show_help { |
| 175 | println(fp.usage()) |
| 176 | exit(0) |
| 177 | } |
| 178 | |
| 179 | if arg_paths.len == 0 { |
| 180 | println(fp.usage()) |
| 181 | println('\nError missing arguments') |
| 182 | exit(1) |
| 183 | } |
| 184 | |
| 185 | if !os.exists(tmp_dir) { |
| 186 | os.mkdir_all(tmp_dir)! |
| 187 | } |
| 188 | |
| 189 | opt.config = new_config(opt.root_path, toml_conf) or { panic(err) } |
| 190 | |
| 191 | gen_in_path := arg_paths[0] |
| 192 | if arg_paths.len >= 1 { |
| 193 | generate_screenshots(mut opt, gen_in_path) or { panic(err) } |
| 194 | } |
| 195 | if arg_paths.len > 1 { |
| 196 | target_path := arg_paths[1] |
| 197 | path := opt.config.path |
| 198 | all_paths_in_use := [path, gen_in_path, target_path] |
| 199 | for path_in_use in all_paths_in_use { |
| 200 | if !os.is_dir(path_in_use) { |
| 201 | eprintln('`${path_in_use}` is not a directory') |
| 202 | exit(1) |
| 203 | } |
| 204 | } |
| 205 | if path == target_path || gen_in_path == target_path || gen_in_path == path { |
| 206 | eprintln('Compare paths can not be the same directory `${path}`/`${target_path}`/`${gen_in_path}`') |
| 207 | exit(1) |
| 208 | } |
| 209 | compare_screenshots(opt, gen_in_path, target_path)! |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | fn generate_screenshots(mut opt Options, output_path string) ! { |
| 214 | path := opt.config.path |
| 215 | |
| 216 | dst_path := output_path.trim_right('/') |
| 217 | |
| 218 | if !os.is_dir(path) { |
| 219 | return error('`${path}` is not a directory') |
| 220 | } |
| 221 | |
| 222 | for mut app_config in opt.config.apps { |
| 223 | file := app_config.path |
| 224 | app_path := app_config.abs_path |
| 225 | |
| 226 | mut rel_out_path := '' |
| 227 | if os.is_file(app_path) { |
| 228 | rel_out_path = os.dir(file) |
| 229 | } else { |
| 230 | rel_out_path = file |
| 231 | } |
| 232 | |
| 233 | if app_config.capture.method == 'gg_record' { |
| 234 | opt.verbose_eprintln('Compiling shaders (if needed) for `${file}`') |
| 235 | sh_result := |
| 236 | opt.verbose_execute('${os.quoted_path(v_exe)} shader ${os.quoted_path(app_path)}') |
| 237 | if sh_result.exit_code != 0 { |
| 238 | opt.verbose_eprintln('Skipping shader compile for `${file}` v shader failed with:\n${sh_result.output}') |
| 239 | continue |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | if !os.exists(dst_path) { |
| 244 | opt.verbose_eprintln('Creating output path `${dst_path}`') |
| 245 | os.mkdir_all(dst_path) or { return error('Failed making directory `${dst_path}`') } |
| 246 | } |
| 247 | |
| 248 | screenshot_path := os.join_path(dst_path, rel_out_path) |
| 249 | if !os.exists(screenshot_path) { |
| 250 | os.mkdir_all(screenshot_path) or { |
| 251 | return error('Failed making screenshot path `${screenshot_path}`') |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | app_config.screenshots_path = screenshot_path |
| 256 | app_config.screenshots = take_screenshots(opt, app_config) or { |
| 257 | return error('Failed taking screenshots of `${app_path}`:\n${err.msg()}') |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | fn compare_screenshots(opt Options, output_path string, target_path string) ! { |
| 263 | mut fails := map[string]string{} |
| 264 | mut warns := map[string]string{} |
| 265 | for app_config in opt.config.apps { |
| 266 | screenshots := app_config.screenshots |
| 267 | opt.verbose_eprintln('Comparing ${screenshots.len} screenshots in `${output_path}` with `${target_path}`') |
| 268 | for screenshot in screenshots { |
| 269 | relative_screenshot := screenshot.all_after(output_path + os.path_separator) |
| 270 | |
| 271 | src := screenshot |
| 272 | target := os.join_path(target_path, relative_screenshot) |
| 273 | opt.verbose_eprintln('Comparing `${src}` with `${target}` with ${app_config.compare.method}') |
| 274 | |
| 275 | if app_config.compare.method == 'idiff' { |
| 276 | if idiff_exe == '' { |
| 277 | return error('${tool_name} need the `idiff` tool installed. It can be installed on Ubuntu with `sudo apt install openimageio-tools`') |
| 278 | } |
| 279 | diff_file := os.join_path(os.vtmp_dir(), os.file_name(src).all_before_last('.') + |
| 280 | '.diff.tif') |
| 281 | flags := app_config.compare.flags.join(' ') |
| 282 | diff_cmd := '${os.quoted_path(idiff_exe)} ${flags} -abs -od -o ${os.quoted_path(diff_file)} -abs ${os.quoted_path(src)} ${os.quoted_path(target)}' |
| 283 | result := opt.verbose_execute(diff_cmd) |
| 284 | if result.exit_code == 0 { |
| 285 | opt.verbose_eprintln('OUTPUT: \n${result.output}') |
| 286 | } |
| 287 | if result.exit_code != 0 { |
| 288 | eprintln('OUTPUT: \n${result.output}') |
| 289 | if result.exit_code == 1 { |
| 290 | warns[src] = target |
| 291 | } else { |
| 292 | fails[src] = target |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | if warns.len > 0 { |
| 300 | eprintln('--- WARNINGS ---') |
| 301 | eprintln('The following files had warnings when compared to their targets') |
| 302 | for warn_src, warn_target in warns { |
| 303 | eprintln('${warn_src} ~= ${warn_target}') |
| 304 | } |
| 305 | } |
| 306 | if fails.len > 0 { |
| 307 | eprintln('--- ERRORS ---') |
| 308 | eprintln('The following files did not match their targets') |
| 309 | for fail_src, fail_target in fails { |
| 310 | eprintln('${fail_src} != ${fail_target}') |
| 311 | } |
| 312 | first := fails.keys()[0] |
| 313 | fail_copy := os.join_path(os.vtmp_dir(), 'fail.' + first.all_after_last('.')) |
| 314 | os.cp(first, fail_copy)! |
| 315 | eprintln('First failed file `${first}` is copied to `${fail_copy}`') |
| 316 | |
| 317 | diff_file := os.join_path(os.vtmp_dir(), os.file_name(first).all_before_last('.') + |
| 318 | '.diff.tif') |
| 319 | diff_copy := os.join_path(os.vtmp_dir(), 'diff.tif') |
| 320 | if os.is_file(diff_file) { |
| 321 | os.cp(diff_file, diff_copy)! |
| 322 | eprintln('First failed diff file `${diff_file}` is copied to `${diff_copy}`') |
| 323 | eprintln('Removing alpha channel from ${diff_copy} ...') |
| 324 | final_fail_result_file := os.join_path(os.vtmp_dir(), 'diff.png') |
| 325 | opt.verbose_execute('convert ${os.quoted_path(diff_copy)} -alpha off ${os.quoted_path(final_fail_result_file)}') |
| 326 | eprintln('Final diff file: `${final_fail_result_file}`') |
| 327 | } |
| 328 | exit(1) |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | fn take_screenshots(opt Options, app AppConfig) ![]string { |
| 333 | out_path := app.screenshots_path |
| 334 | if !opt.compare_only { |
| 335 | opt.verbose_eprintln('Taking screenshot(s) of `${app.path}` to `${out_path}`') |
| 336 | match app.capture.method { |
| 337 | 'gg_record' { |
| 338 | for k, v in app.capture.env { |
| 339 | rv := v.replace('\$OUT_PATH', out_path) |
| 340 | opt.verbose_eprintln('Setting ENV `${k}` = ${rv} ...') |
| 341 | os.setenv('${k}', rv, true) |
| 342 | } |
| 343 | |
| 344 | flags := app.capture.flags.join(' ') |
| 345 | result := |
| 346 | opt.verbose_execute('${os.quoted_path(v_exe)} ${flags} -d gg_record run ${os.quoted_path(app.abs_path)}') |
| 347 | if result.exit_code != 0 { |
| 348 | return error('Failed taking screenshot of `${app.abs_path}`:\n${result.output}') |
| 349 | } |
| 350 | } |
| 351 | 'generic_screenshot' { |
| 352 | for k, v in app.capture.env { |
| 353 | rv := v.replace('\$OUT_PATH', out_path) |
| 354 | opt.verbose_eprintln('Setting ENV `${k}` = ${rv} ...') |
| 355 | os.setenv('${k}', rv, true) |
| 356 | } |
| 357 | |
| 358 | existing_screenshots := get_app_screenshots(out_path, app)! |
| 359 | |
| 360 | flags := app.capture.flags |
| 361 | |
| 362 | if !os.exists(app.abs_path) { |
| 363 | return error('Failed starting app `${app.abs_path}`, the path does not exist') |
| 364 | } |
| 365 | opt.verbose_eprintln('Running ${app.abs_path} ${flags}') |
| 366 | mut p_app := os.new_process(app.abs_path) |
| 367 | p_app.set_args(flags) |
| 368 | p_app.set_redirect_stdio() |
| 369 | p_app.run() |
| 370 | |
| 371 | if !p_app.is_alive() { |
| 372 | output := p_app.stdout_read() + '\n' + p_app.stderr_read() |
| 373 | return error('Failed starting app `${app.abs_path}` (before screenshot):\n${output}') |
| 374 | } |
| 375 | if app.capture.wait_ms > 0 { |
| 376 | opt.verbose_eprintln('Waiting ${app.capture.wait_ms} before capturing') |
| 377 | time.sleep(app.capture.wait_ms * time.millisecond) |
| 378 | } |
| 379 | if !p_app.is_alive() { |
| 380 | output := p_app.stdout_slurp() + '\n' + p_app.stderr_slurp() |
| 381 | return error('App `${app.abs_path}` exited (${p_app.code}) before a screenshot could be captured:\n${output}') |
| 382 | } |
| 383 | // Use ImageMagick's `import` tool to take the screenshot |
| 384 | out_file := os.join_path(out_path, os.file_name(app.path) + |
| 385 | '_screenshot_${existing_screenshots.len:02}.png') |
| 386 | result := opt.verbose_execute('import -window root "${out_file}"') |
| 387 | if result.exit_code != 0 { |
| 388 | p_app.signal_kill() |
| 389 | return error('Failed taking screenshot of `${app.abs_path}` to "${out_file}":\n${result.output}') |
| 390 | } |
| 391 | |
| 392 | // When using regions the capture is split up into regions.len |
| 393 | // And name the output based on each region's properties |
| 394 | if app.capture.regions.len > 0 { |
| 395 | for region in app.capture.regions { |
| 396 | region_id := 'x${region.x}y${region.y}w${region.width}h${region.height}' |
| 397 | region_out_file := os.join_path(out_path, os.file_name(app.path) + |
| 398 | '_screenshot_${existing_screenshots.len:02}_region_${region_id}.png') |
| 399 | // If the region is empty (w, h == 0, 0) infer a full screenshot, |
| 400 | // This allows for capturing both regions *and* the complete screen |
| 401 | if region.is_empty() { |
| 402 | os.cp(out_file, region_out_file) or { |
| 403 | return error('Failed copying original screenshot "${out_file}" to region file "${region_out_file}"') |
| 404 | } |
| 405 | continue |
| 406 | } |
| 407 | extract_result := |
| 408 | opt.verbose_execute('convert -extract ${region.width}x${region.height}+${region.x}+${region.y} "${out_file}" "${region_out_file}"') |
| 409 | if extract_result.exit_code != 0 { |
| 410 | p_app.signal_kill() |
| 411 | return error('Failed extracting region ${region_id} from screenshot of `${app.abs_path}` to "${region_out_file}":\n${result.output}') |
| 412 | } |
| 413 | } |
| 414 | // When done, remove the original file that was split into regions. |
| 415 | opt.verbose_eprintln('Removing "${out_file}" (region mode)') |
| 416 | os.rm(out_file) or { |
| 417 | return error('Failed removing original screenshot "${out_file}"') |
| 418 | } |
| 419 | } |
| 420 | p_app.signal_kill() |
| 421 | } |
| 422 | else { |
| 423 | return error('Unsupported capture method "${app.capture.method}"') |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | return get_app_screenshots(out_path, app)! |
| 428 | } |
| 429 | |
| 430 | fn get_app_screenshots(path string, app AppConfig) ![]string { |
| 431 | mut screenshots := []string{} |
| 432 | shots := os.ls(path) or { return error('Failed listing dir `${path}`') } |
| 433 | for shot in shots { |
| 434 | if shot.starts_with(os.file_name(app.path).all_before_last('.')) { |
| 435 | screenshots << os.join_path(path, shot) |
| 436 | } |
| 437 | } |
| 438 | return screenshots |
| 439 | } |
| 440 | |
| 441 | fn new_config(root_path string, toml_config string) !Config { |
| 442 | doc := if os.is_file(toml_config) { |
| 443 | toml.parse_file(toml_config) or { return error(err.msg()) } |
| 444 | } else { |
| 445 | toml.parse_text(toml_config) or { return error(err.msg()) } |
| 446 | } |
| 447 | |
| 448 | path := os.real_path(root_path).trim_right('/') |
| 449 | |
| 450 | compare_method := doc.value('compare.method').default_to('idiff').string() |
| 451 | compare_flags := doc.value('compare.flags').default_to(empty_toml_array).array().as_strings() |
| 452 | default_compare := CompareOptions{ |
| 453 | method: compare_method |
| 454 | flags: compare_flags |
| 455 | } |
| 456 | capture_method := doc.value('capture.method').default_to('gg_record').string() |
| 457 | capture_flags := doc.value('capture.flags').default_to(empty_toml_array).array().as_strings() |
| 458 | capture_regions_any := doc.value('capture.regions').default_to(empty_toml_array).array() |
| 459 | mut capture_regions := []CaptureRegion{} |
| 460 | for capture_region_any in capture_regions_any { |
| 461 | region := CaptureRegion{ |
| 462 | x: capture_region_any.value('x').default_to(0).int() |
| 463 | y: capture_region_any.value('y').default_to(0).int() |
| 464 | width: capture_region_any.value('width').default_to(0).int() |
| 465 | height: capture_region_any.value('height').default_to(0).int() |
| 466 | } |
| 467 | capture_regions << region |
| 468 | } |
| 469 | capture_wait_ms := doc.value('capture.wait_ms').default_to(0).int() |
| 470 | capture_env := doc.value('capture.env').default_to(empty_toml_map).as_map() |
| 471 | mut env_map := map[string]string{} |
| 472 | for k, v in capture_env { |
| 473 | env_map[k] = v.string() |
| 474 | } |
| 475 | default_capture := CaptureOptions{ |
| 476 | method: capture_method |
| 477 | wait_ms: capture_wait_ms |
| 478 | flags: capture_flags |
| 479 | regions: capture_regions |
| 480 | env: env_map |
| 481 | } |
| 482 | |
| 483 | apps_any := doc.value('apps').default_to(empty_toml_array).array() |
| 484 | mut apps := []AppConfig{cap: apps_any.len} |
| 485 | for app_any in apps_any { |
| 486 | rel_path := app_any.value('path').string().trim_right('/') |
| 487 | |
| 488 | // Merge, per app, overwrites |
| 489 | mut merged_compare := CompareOptions{} |
| 490 | merged_compare.method = |
| 491 | app_any.value('compare.method').default_to(default_compare.method).string() |
| 492 | merged_compare_flags := |
| 493 | app_any.value('compare.flags').default_to(empty_toml_array).array().as_strings() |
| 494 | if merged_compare_flags.len > 0 { |
| 495 | merged_compare.flags = merged_compare_flags |
| 496 | } else { |
| 497 | merged_compare.flags = default_compare.flags |
| 498 | } |
| 499 | |
| 500 | mut merged_capture := CaptureOptions{} |
| 501 | merged_capture.method = |
| 502 | app_any.value('capture.method').default_to(default_capture.method).string() |
| 503 | merged_capture_flags := |
| 504 | app_any.value('capture.flags').default_to(empty_toml_array).array().as_strings() |
| 505 | if merged_capture_flags.len > 0 { |
| 506 | merged_capture.flags = merged_capture_flags |
| 507 | } else { |
| 508 | merged_capture.flags = default_capture.flags |
| 509 | } |
| 510 | |
| 511 | app_capture_regions_any := |
| 512 | app_any.value('capture.regions').default_to(empty_toml_array).array() |
| 513 | mut app_capture_regions := []CaptureRegion{} |
| 514 | for capture_region_any in app_capture_regions_any { |
| 515 | region := CaptureRegion{ |
| 516 | x: capture_region_any.value('x').default_to(0).int() |
| 517 | y: capture_region_any.value('y').default_to(0).int() |
| 518 | width: capture_region_any.value('width').default_to(0).int() |
| 519 | height: capture_region_any.value('height').default_to(0).int() |
| 520 | } |
| 521 | app_capture_regions << region |
| 522 | } |
| 523 | mut merged_capture_regions := []CaptureRegion{} |
| 524 | for default_capture_region in default_capture.regions { |
| 525 | if default_capture_region !in app_capture_regions { |
| 526 | merged_capture_regions << default_capture_region |
| 527 | } |
| 528 | } |
| 529 | for app_capture_region in app_capture_regions { |
| 530 | if app_capture_region !in default_capture.regions { |
| 531 | merged_capture_regions << app_capture_region |
| 532 | } |
| 533 | } |
| 534 | merged_capture.regions = merged_capture_regions |
| 535 | |
| 536 | merged_capture.wait_ms = |
| 537 | app_any.value('capture.wait_ms').default_to(default_capture.wait_ms).int() |
| 538 | merge_capture_env := app_any.value('capture.env').default_to(empty_toml_map).as_map() |
| 539 | mut merge_env_map := default_capture.env.clone() |
| 540 | for k, v in merge_capture_env { |
| 541 | merge_env_map[k] = v.string() |
| 542 | } |
| 543 | for k, v in merge_env_map { |
| 544 | merged_capture.env[k] = v |
| 545 | } |
| 546 | |
| 547 | merged_capture.validate()! |
| 548 | |
| 549 | app_config := AppConfig{ |
| 550 | compare: merged_compare |
| 551 | capture: merged_capture |
| 552 | path: rel_path |
| 553 | abs_path: os.join_path(path, rel_path).trim_right('/') |
| 554 | } |
| 555 | apps << app_config |
| 556 | } |
| 557 | |
| 558 | return Config{ |
| 559 | apps: apps |
| 560 | path: path |
| 561 | } |
| 562 | } |
| 563 | |