package main import "core:fmt" import "core:os" import "core:strconv" import "core:strings" import "../adapters" import "../core" import "../gui" import "../shared" import "../ui" CLI_Command_Kind :: enum { Demo, Status, Save, Load, Tui, Gui, Help, } Parsed_CLI_Command :: struct { kind: CLI_Command_Kind, path: string, } parse_cli_command :: proc(args: []string) -> Parsed_CLI_Command { if len(args) == 0 { return Parsed_CLI_Command{kind = .Demo} } switch args[0] { case "status": return Parsed_CLI_Command{kind = .Status} case "save": if len(args) >= 2 { return Parsed_CLI_Command{kind = .Save, path = args[1]} } return Parsed_CLI_Command{kind = .Help} case "load": if len(args) >= 2 { return Parsed_CLI_Command{kind = .Load, path = args[1]} } return Parsed_CLI_Command{kind = .Help} case "tui": return Parsed_CLI_Command{kind = .Tui} case "gui": return Parsed_CLI_Command{kind = .Gui} case "help", "-h", "--help": return Parsed_CLI_Command{kind = .Help} } return Parsed_CLI_Command{kind = .Help} } usage_text :: proc() -> string { return "Usage: comic_odin [status|save |load |tui|gui|help]" } tui_help_text :: proc() -> string { return "TUI commands: help|h, status|s, doctor|?, ready|r, next|n, plan|p, auto|x, auto all , auto all local [pages], new, set idea , set genre , set audience , generate script [pages], generate script local [pages], generate panels [page ], generate panels local [page ], layout auto, export , quick local [pages], quick local all [pages], goto (or 1..8), step , save|saveas , load|open , start , progress <0-100>, done|d, fail , cancel|c, quit|q" } bool_text :: proc(v: bool) -> string { if v { return "yes" } return "no" } export_format_name :: proc(f: core.Export_Format) -> string { switch f { case .PDF: return "pdf" case .PNG: return "png" case .CBZ: return "cbz" } return "pdf" } command_available :: proc(name: string) -> bool { cmd := [2]string{"which", name} desc := os.Process_Desc{command = cmd[:]} state, _, _, err := os.process_exec(desc, context.temp_allocator) if err != nil { return false } return state.exited && state.exit_code == 0 } build_doctor_report :: proc() -> string { cfg := shared.load_config() has_deepseek := len(cfg.deepseek_api_key) > 0 has_fal := len(cfg.fal_api_key) > 0 has_curl := command_available("curl") has_python := command_available("python3") return fmt.aprintf("Doctor\n- deepseek key: %s\n- fal key: %s\n- curl: %s\n- python3: %s", bool_text(has_deepseek), bool_text(has_fal), bool_text(has_curl), bool_text(has_python)) } build_ready_report :: proc(c: ui.App_Controller) -> string { has_script := len(c.state.script.pages) > 0 has_panels := len(c.state.panel_images) > 0 has_layout := len(c.state.page_layouts) > 0 can_export := has_layout && has_panels return fmt.aprintf("Ready\n- script generated: %s\n- panel images generated: %s\n- layout generated: %s\n- export ready: %s", bool_text(has_script), bool_text(has_panels), bool_text(has_layout), bool_text(can_export)) } next_action_hint :: proc(c: ui.App_Controller) -> string { cfg := shared.load_config() if len(c.state.script.pages) == 0 { if len(cfg.deepseek_api_key) > 0 { return "next: generate script 4" } return "next: generate script local 2" } if len(c.state.panel_images) == 0 { if len(cfg.fal_api_key) > 0 { return "next: generate panels" } return "next: generate panels local" } if len(c.state.page_layouts) == 0 { return "next: layout auto" } return "next: export pdf ./comic.pdf" } plan_report :: proc(c: ui.App_Controller) -> string { script_done := len(c.state.script.pages) > 0 panels_done := len(c.state.panel_images) > 0 layout_done := len(c.state.page_layouts) > 0 export_ready := panels_done && layout_done return fmt.aprintf( "Plan\n- [%-3s] 1) Script\n- [%-3s] 2) Panels\n- [%-3s] 3) Layout\n- [%-3s] 4) Export\n%s", bool_text(script_done), bool_text(panels_done), bool_text(layout_done), bool_text(export_ready), next_action_hint(c), ) } collect_script_panels :: proc(script: core.Comic_Script) -> []core.Panel { out: [dynamic]core.Panel for p in script.pages { for pan in p.panels { append(&out, pan) } } return out[:] } collect_script_panels_for_page :: proc(script: core.Comic_Script, page_number: int) -> []core.Panel { out: [dynamic]core.Panel for p in script.pages { if p.page_number != page_number { continue } for pan in p.panels { append(&out, pan) } } return out[:] } parse_generate_script_pages :: proc(input: string) -> (int, bool, shared.App_Error) { trimmed := strings.trim_space(input) if trimmed == "generate script" { return 4, true, shared.ok() } if !strings.has_prefix(trimmed, "generate script ") || strings.has_prefix(trimmed, "generate script local") { return 0, false, shared.ok() } raw := strings.trim_space(trimmed[len("generate script "):]) pages, ok := strconv.parse_int(raw) if !ok || pages <= 0 { return 0, true, shared.validation_error("generate script pages must be a positive integer") } return pages, true, shared.ok() } parse_generate_script_local_pages :: proc(input: string) -> (int, bool, shared.App_Error) { trimmed := strings.trim_space(input) if trimmed == "generate script local" { return 4, true, shared.ok() } if !strings.has_prefix(trimmed, "generate script local ") { return 0, false, shared.ok() } raw := strings.trim_space(trimmed[len("generate script local "):]) pages, ok := strconv.parse_int(raw) if !ok || pages <= 0 { return 0, true, shared.validation_error("generate script local pages must be a positive integer") } return pages, true, shared.ok() } local_panel_id_by_index :: proc(i: int) -> string { switch i { case 0: return "panel_local_001" case 1: return "panel_local_002" case 2: return "panel_local_003" case 3: return "panel_local_004" case 4: return "panel_local_005" case 5: return "panel_local_006" case 6: return "panel_local_007" case 7: return "panel_local_008" case 8: return "panel_local_009" } return "panel_local_overflow" } build_local_script :: proc(story_idea: string, pages: int) -> core.Comic_Script { out_pages: [dynamic]core.Page for i in 0.. (int, bool, shared.App_Error) { trimmed := strings.trim_space(input) if trimmed == "generate panels" { return 0, true, shared.ok() } if !strings.has_prefix(trimmed, "generate panels page ") || strings.has_prefix(trimmed, "generate panels local") { return 0, false, shared.ok() } raw := strings.trim_space(trimmed[len("generate panels page "):]) page, ok := strconv.parse_int(raw) if !ok || page <= 0 { return 0, true, shared.validation_error("generate panels page must be a positive integer") } return page, true, shared.ok() } parse_generate_panels_local_page :: proc(input: string) -> (int, bool, shared.App_Error) { trimmed := strings.trim_space(input) if trimmed == "generate panels local" { return 0, true, shared.ok() } if !strings.has_prefix(trimmed, "generate panels local page ") { return 0, false, shared.ok() } raw := strings.trim_space(trimmed[len("generate panels local page "):]) page, ok := strconv.parse_int(raw) if !ok || page <= 0 { return 0, true, shared.validation_error("generate panels local page must be a positive integer") } return page, true, shared.ok() } build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel_Image, shared.App_Error) { tmp_dir, terr := os.make_directory_temp("", "comic-local-panels-*", context.temp_allocator) if terr != nil { return nil, shared.new_error(.Generation, "failed to create local panel temp dir", true) } images := make(map[string]core.Panel_Image) for p, idx in panels { name := fmt.aprintf("panel_%03d_%s.png", idx+1, p.panel_id) out_path := fmt.aprintf("%s/%s", tmp_dir, name) delete(name) // Create a real PNG image using python3 gen_w := 1024 gen_h := 1024 py_script := fmt.aprintf( "import struct,zlib,sys;w,h=%d,%d;rows=[]\nfor _ in range(h): rows.append(b'\\x00'+b'\\xff\\xff\\xff'*w)\nraw=b''.join(rows);comp=zlib.compress(raw)\ndef crc(d): return struct.pack('>I',zlib.crc32(d)&0xffffffff)\nf=open(sys.argv[1],'wb')\nf.write(b'\\x89PNG\\r\\n\\x1a\\n')\nihdr_data=struct.pack('>IIBBBBB',w,h,8,2,0,0,0)\nf.write(struct.pack('>I',13)+b'IHDR'+ihdr_data+crc(b'IHDR'+ihdr_data))\nf.write(struct.pack('>I',len(comp))+b'IDAT'+comp+crc(b'IDAT'+comp))\nf.write(struct.pack('>I',0)+b'IEND'+crc(b'IEND'))\nf.close()", gen_w, gen_h, ) defer delete(py_script) py_cmd := [4]string{"python3", "-c", py_script, out_path} desc := os.Process_Desc{command = py_cmd[:]} state, _, stderr, cerr := os.process_exec(desc, context.temp_allocator) if cerr != nil || !state.exited || state.exit_code != 0 { delete(out_path) msg := fmt.aprintf("failed to create panel image: %s", string(stderr)) return nil, shared.new_error(.Generation, msg, true) } url := strings.clone(fmt.aprintf("file://%s", out_path)) prompt := strings.clone(fmt.aprintf("local panel %d", idx+1)) images[p.panel_id] = core.Panel_Image{url = url, width = gen_w, height = gen_h, seed = i64(idx + 1), prompt = prompt} delete(out_path) } return images, shared.ok() } parse_export_command :: proc(input: string) -> (core.Export_Format, string, bool, shared.App_Error) { trimmed := strings.trim_space(input) if !strings.has_prefix(trimmed, "export ") { return .PDF, "", false, shared.ok() } rest := strings.trim_space(trimmed[len("export "):]) sp := strings.index(rest, " ") if sp < 0 { return .PDF, "", true, shared.validation_error("usage: export ") } fmt_name := strings.trim_space(rest[:sp]) out_path := strings.trim_space(rest[sp+1:]) if len(out_path) == 0 { return .PDF, "", true, shared.validation_error("export path is required") } switch fmt_name { case "pdf": return .PDF, out_path, true, shared.ok() case "png": return .PNG, out_path, true, shared.ok() case "cbz": return .CBZ, out_path, true, shared.ok() } return .PDF, "", true, shared.validation_error("unknown export format") } parse_quick_local_command :: proc(input: string) -> (core.Export_Format, string, int, bool, shared.App_Error) { trimmed := strings.trim_space(input) if !strings.has_prefix(trimmed, "quick local ") || strings.has_prefix(trimmed, "quick local all ") { return .PDF, "", 0, false, shared.ok() } rest := strings.trim_space(trimmed[len("quick local "):]) parts, ferr := strings.fields(rest) if ferr != nil { return .PDF, "", 0, true, shared.new_error(.Validation, "quick local parse failed", true) } defer delete(parts) if len(parts) < 2 || len(parts) > 3 { return .PDF, "", 0, true, shared.validation_error("usage: quick local [pages]") } fmt_name := parts[0] out_path := parts[1] if len(out_path) == 0 { return .PDF, "", 0, true, shared.validation_error("quick local export path is required") } pages := 2 if len(parts) == 3 { v, ok := strconv.parse_int(parts[2]) if !ok || v <= 0 { return .PDF, "", 0, true, shared.validation_error("quick local pages must be a positive integer") } pages = v } switch fmt_name { case "pdf": return .PDF, out_path, pages, true, shared.ok() case "png": return .PNG, out_path, pages, true, shared.ok() case "cbz": return .CBZ, out_path, pages, true, shared.ok() } return .PDF, "", 0, true, shared.validation_error("unknown quick local export format") } parse_quick_local_all_command :: proc(input: string) -> (string, core.Export_Format, string, int, bool, shared.App_Error) { trimmed := strings.trim_space(input) if !strings.has_prefix(trimmed, "quick local all ") { return "", .PDF, "", 0, false, shared.ok() } rest := strings.trim_space(trimmed[len("quick local all "):]) parts, ferr := strings.fields(rest) if ferr != nil { return "", .PDF, "", 0, true, shared.new_error(.Validation, "quick local all parse failed", true) } defer delete(parts) if len(parts) < 3 || len(parts) > 4 { return "", .PDF, "", 0, true, shared.validation_error("usage: quick local all [pages]") } project_path := parts[0] fmt_name := parts[1] export_path := parts[2] if len(project_path) == 0 || len(export_path) == 0 { return "", .PDF, "", 0, true, shared.validation_error("quick local all paths are required") } pages := 2 if len(parts) == 4 { v, ok := strconv.parse_int(parts[3]) if !ok || v <= 0 { return "", .PDF, "", 0, true, shared.validation_error("quick local all pages must be a positive integer") } pages = v } switch fmt_name { case "pdf": return project_path, .PDF, export_path, pages, true, shared.ok() case "png": return project_path, .PNG, export_path, pages, true, shared.ok() case "cbz": return project_path, .CBZ, export_path, pages, true, shared.ok() } return "", .PDF, "", 0, true, shared.validation_error("unknown quick local all export format") } parse_auto_all_command :: proc(input: string) -> (core.Export_Format, string, bool, shared.App_Error) { trimmed := strings.trim_space(input) if !strings.has_prefix(trimmed, "auto all ") || strings.has_prefix(trimmed, "auto all local ") { return .PDF, "", false, shared.ok() } rest := strings.trim_space(trimmed[len("auto all "):]) parts, ferr := strings.fields(rest) if ferr != nil { return .PDF, "", true, shared.new_error(.Validation, "auto all parse failed", true) } defer delete(parts) if len(parts) != 2 { return .PDF, "", true, shared.validation_error("usage: auto all ") } switch parts[0] { case "pdf": return .PDF, parts[1], true, shared.ok() case "png": return .PNG, parts[1], true, shared.ok() case "cbz": return .CBZ, parts[1], true, shared.ok() } return .PDF, "", true, shared.validation_error("unknown auto all export format") } parse_auto_all_local_command :: proc(input: string) -> (core.Export_Format, string, int, bool, shared.App_Error) { trimmed := strings.trim_space(input) if !strings.has_prefix(trimmed, "auto all local ") { return .PDF, "", 0, false, shared.ok() } rest := strings.trim_space(trimmed[len("auto all local "):]) parts, ferr := strings.fields(rest) if ferr != nil { return .PDF, "", 0, true, shared.new_error(.Validation, "auto all local parse failed", true) } defer delete(parts) if len(parts) < 2 || len(parts) > 3 { return .PDF, "", 0, true, shared.validation_error("usage: auto all local [pages]") } pages := 2 if len(parts) == 3 { v, ok := strconv.parse_int(parts[2]) if !ok || v <= 0 { return .PDF, "", 0, true, shared.validation_error("auto all local pages must be a positive integer") } pages = v } switch parts[0] { case "pdf": return .PDF, parts[1], pages, true, shared.ok() case "png": return .PNG, parts[1], pages, true, shared.ok() case "cbz": return .CBZ, parts[1], pages, true, shared.ok() } return .PDF, "", 0, true, shared.validation_error("unknown auto all local export format") } run_quick_local_pipeline :: proc(controller: ^ui.App_Controller, export_format: core.Export_Format, export_path: string, pages: int) -> shared.App_Error { story := controller.state.story_idea if len(story) == 0 { story = "A local adventure" } script := build_local_script(story, pages) core.dispose_script(&controller.state.script) controller.state.script = script controller.state.characters = controller.state.script.characters panels := collect_script_panels(controller.state.script) defer delete(panels) if len(panels) == 0 { return shared.validation_error("quick local failed: no panels") } images, lerr := build_local_panel_images(panels) if !shared.is_ok(lerr) { return lerr } for _, img in controller.state.panel_images { delete(img.url) delete(img.prompt) } delete(controller.state.panel_images) controller.state.panel_images = images core.dispose_page_layouts(&controller.state.page_layouts) controller.state.page_layouts = core.auto_layout_pages(panels, controller.state.page_size, controller.state.story_genre, "") opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90} err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts) if !shared.is_ok(err) { controller.state.workflow.error_message = err.message return err } controller.state.export_format = export_format controller.active_screen = .Export controller.state.workflow.current_step = .Complete controller.state.workflow.error_message = "" return shared.ok() } clear_screen :: proc() { fmt.print("\x1b[2J\x1b[H") } normalize_tui_command :: proc(input: string) -> string { cmd := strings.trim_space(input) switch cmd { case "h": return "help" case "s": return "status" case "?": return "doctor" case "r": return "ready" case "n": return "next" case "p": return "plan" case "x": return "auto" case "d": return "done" case "c": return "cancel" case "q": return "quit" case "1": return "goto story" case "2": return "goto script" case "3": return "goto characters" case "4": return "goto panels" case "5": return "goto layout" case "6": return "goto bubbles" case "7": return "goto export" case "8": return "goto community" } return cmd } screen_from_name :: proc(s: string) -> (ui.App_Screen, bool) { switch s { case "story": return .Story, true case "script": return .Script, true case "characters": return .Characters, true case "panels": return .Panels, true case "layout": return .Layout, true case "bubbles", "speech": return .Bubbles, true case "export": return .Export, true case "community": return .Community, true } return .Story, false } workflow_from_name :: proc(s: string) -> (core.Workflow_Step, bool) { switch s { case "story": return .Story_Input, true case "generating-script": return .Generating_Script, true case "script-review": return .Script_Review, true case "character-setup": return .Character_Setup, true case "generating-panels": return .Generating_Panels, true case "layout": return .Layout, true case "speech-bubbles": return .Speech_Bubbles, true case "complete": return .Complete, true } return .Story_Input, false } job_type_from_name :: proc(s: string) -> (ui.Job_Type, bool) { switch s { case "script": return .Generate_Script, true case "character": return .Generate_Character, true case "panel": return .Generate_Panel, true case "export": return .Export, true } return .Generate_Script, false } read_stdin_line :: proc() -> (line: string, ok: bool, err: shared.App_Error) { buf: [dynamic]u8 one: [1]u8 for { n, rerr := os.read(os.stdin, one[:]) if rerr != nil { if rerr == .EOF { if len(buf) == 0 { return "", false, shared.ok() } break } delete(buf) return "", false, shared.new_error(.Config, "stdin read error", true) } if n == 0 { continue } if one[0] == '\n' { break } if one[0] != '\r' { append(&buf, one[0]) } } return string(buf[:]), true, shared.ok() } run_tui_command :: proc(controller: ^ui.App_Controller, input: string, last_job_id: ^int) -> (quit: bool, out: string, err: shared.App_Error) { if input == "help" { return false, fmt.aprintf("%s", tui_help_text()), shared.ok() } if input == "status" { return false, ui.render_app_to_string(controller^), shared.ok() } if input == "doctor" { return false, build_doctor_report(), shared.ok() } if input == "ready" { return false, build_ready_report(controller^), shared.ok() } if input == "next" { return false, fmt.aprintf("%s", next_action_hint(controller^)), shared.ok() } if input == "plan" { return false, plan_report(controller^), shared.ok() } auto_all_local_format, auto_all_local_path, auto_all_local_pages, is_auto_all_local, aalerr := parse_auto_all_local_command(input) if !shared.is_ok(aalerr) { return false, "", aalerr } if is_auto_all_local { if err := run_quick_local_pipeline(controller, auto_all_local_format, auto_all_local_path, auto_all_local_pages); !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("auto all local exported %s (%d pages)", auto_all_local_path, auto_all_local_pages), shared.ok() } auto_all_format, auto_all_path, is_auto_all, aaerr := parse_auto_all_command(input) if !shared.is_ok(aaerr) { return false, "", aaerr } if is_auto_all { for i in 0..<6 { hint := next_action_hint(controller^) if strings.has_prefix(hint, "next: export ") { export_cmd := fmt.aprintf("export %s %s", export_format_name(auto_all_format), auto_all_path) _, out, eerr := run_tui_command(controller, export_cmd, last_job_id) delete(export_cmd) if !shared.is_ok(eerr) { return false, "", eerr } if len(out) > 0 { delete(out) } return false, fmt.aprintf("auto all exported %s", auto_all_path), shared.ok() } if !strings.has_prefix(hint, "next: ") { return false, "", shared.validation_error("auto all failed: invalid next hint") } next_cmd := strings.trim_space(hint[len("next: "):]) _, out, aerr := run_tui_command(controller, next_cmd, last_job_id) if len(out) > 0 { delete(out) } if !shared.is_ok(aerr) { return false, "", aerr } } return false, "", shared.validation_error("auto all exceeded step limit") } if input == "auto" { hint := next_action_hint(controller^) if !strings.has_prefix(hint, "next: ") { return false, "", shared.validation_error("auto failed: invalid next hint") } next_cmd := strings.trim_space(hint[len("next: "):]) _, out, aerr := run_tui_command(controller, next_cmd, last_job_id) if !shared.is_ok(aerr) { return false, "", aerr } if len(out) > 0 { msg := fmt.aprintf("auto ran: %s\n%s", next_cmd, out) delete(out) return false, msg, shared.ok() } return false, fmt.aprintf("auto ran: %s", next_cmd), shared.ok() } if input == "done" { if last_job_id^ <= 0 { return false, "", shared.validation_error("no active job") } err = ui.finish_background_job(controller, last_job_id^, "") if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("job marked done"), shared.ok() } if input == "cancel" { if last_job_id^ <= 0 { return false, "", shared.validation_error("no active job") } err = ui.cancel_background_job(controller, last_job_id^) if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("job cancelled"), shared.ok() } if input == "quit" || input == "exit" { return true, fmt.aprintf("exiting tui"), shared.ok() } if strings.has_prefix(input, "goto ") { target_name := strings.trim_space(input[len("goto "):]) target, ok := screen_from_name(target_name) if !ok { return false, "", shared.validation_error("unknown screen") } err = ui.navigate_to_screen(controller, target) if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("screen: %s", ui.screen_name(target)), shared.ok() } if strings.has_prefix(input, "step ") { step_name := strings.trim_space(input[len("step "):]) next, ok := workflow_from_name(step_name) if !ok { return false, "", shared.validation_error("unknown workflow step") } err = ui.set_workflow_step(controller, next) if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("step: %v", next), shared.ok() } if strings.trim_space(input) == "new" { core.dispose_state(&controller.state) controller.state = core.new_initial_state() controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) last_job_id^ = 0 return false, fmt.aprintf("new project initialized"), shared.ok() } local_pages, is_generate_local_script, lserr := parse_generate_script_local_pages(input) if !shared.is_ok(lserr) { return false, "", lserr } if is_generate_local_script { core.set_workflow_step(&controller.state, .Generating_Script) controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) controller.state.workflow.is_generating = true controller.state.workflow.generation_progress = 25 script := build_local_script(controller.state.story_idea, local_pages) core.dispose_script(&controller.state.script) controller.state.script = script controller.state.characters = controller.state.script.characters core.set_workflow_step(&controller.state, .Script_Review) controller.active_screen = .Script controller.state.workflow.is_generating = false controller.state.workflow.generation_progress = 100 controller.state.workflow.error_message = "" return false, fmt.aprintf("local script generated (%d pages)", local_pages), shared.ok() } script_pages, is_generate_script, pserr := parse_generate_script_pages(input) if !shared.is_ok(pserr) { return false, "", pserr } if is_generate_script { cfg := shared.load_config() core.set_workflow_step(&controller.state, .Generating_Script) controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) controller.state.workflow.is_generating = true controller.state.workflow.generation_progress = 25 opts := adapters.Generate_Script_Options{ story_idea = controller.state.story_idea, genre = controller.state.story_genre, art_style = controller.state.art_style, num_pages = script_pages, audience = controller.state.target_audience, } script, gerr := adapters.generate_comic_script_stub(cfg, opts) if !shared.is_ok(gerr) { controller.state.workflow.is_generating = false controller.state.workflow.error_message = gerr.message controller.state.workflow.current_step = .Story_Input controller.active_screen = .Story return false, "", gerr } core.dispose_script(&controller.state.script) controller.state.script = script controller.state.characters = controller.state.script.characters core.set_workflow_step(&controller.state, .Script_Review) controller.active_screen = .Script controller.state.workflow.is_generating = false controller.state.workflow.generation_progress = 100 controller.state.workflow.error_message = "" return false, fmt.aprintf("script generated (%d pages)", script_pages), shared.ok() } panel_local_page, is_generate_panels_local, plerr := parse_generate_panels_local_page(input) if !shared.is_ok(plerr) { return false, "", plerr } if is_generate_panels_local { panels: []core.Panel if panel_local_page > 0 { panels = collect_script_panels_for_page(controller.state.script, panel_local_page) } else { panels = collect_script_panels(controller.state.script) } defer delete(panels) if len(panels) == 0 { return false, "", shared.validation_error("no script panels available") } core.set_workflow_step(&controller.state, .Generating_Panels) controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) controller.state.workflow.is_generating = true controller.state.workflow.generation_progress = 35 images, gerr := build_local_panel_images(panels) if !shared.is_ok(gerr) { controller.state.workflow.is_generating = false controller.state.workflow.error_message = gerr.message return false, "", gerr } for _, img in controller.state.panel_images { delete(img.url) delete(img.prompt) } delete(controller.state.panel_images) controller.state.panel_images = images core.set_workflow_step(&controller.state, .Layout) controller.active_screen = .Layout controller.state.workflow.is_generating = false controller.state.workflow.generation_progress = 100 controller.state.workflow.error_message = "" if panel_local_page > 0 { return false, fmt.aprintf("local panel images generated for page %d (%d)", panel_local_page, len(images)), shared.ok() } return false, fmt.aprintf("local panel images generated (%d)", len(images)), shared.ok() } panel_page, is_generate_panels, pperr := parse_generate_panels_page(input) if !shared.is_ok(pperr) { return false, "", pperr } if is_generate_panels { cfg := shared.load_config() panels: []core.Panel if panel_page > 0 { panels = collect_script_panels_for_page(controller.state.script, panel_page) } else { panels = collect_script_panels(controller.state.script) } defer delete(panels) if len(panels) == 0 { return false, "", shared.validation_error("no script panels available") } core.set_workflow_step(&controller.state, .Generating_Panels) controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) controller.state.workflow.is_generating = true controller.state.workflow.generation_progress = 35 q := adapters.new_fal_queue(2) client := adapters.new_fal_client(&q) images, gerr := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, controller.state.project.project_id, controller.state.story_genre, controller.state.target_audience, nil) if !shared.is_ok(gerr) { controller.state.workflow.is_generating = false controller.state.workflow.error_message = gerr.message return false, "", gerr } for _, img in controller.state.panel_images { delete(img.url) delete(img.prompt) } delete(controller.state.panel_images) controller.state.panel_images = images core.set_workflow_step(&controller.state, .Layout) controller.active_screen = .Layout controller.state.workflow.is_generating = false controller.state.workflow.generation_progress = 100 controller.state.workflow.error_message = "" if panel_page > 0 { return false, fmt.aprintf("panel images generated for page %d (%d)", panel_page, len(images)), shared.ok() } return false, fmt.aprintf("panel images generated (%d)", len(images)), shared.ok() } if strings.trim_space(input) == "layout auto" { panels := collect_script_panels(controller.state.script) defer delete(panels) if len(panels) == 0 { return false, "", shared.validation_error("no script panels available") } core.dispose_page_layouts(&controller.state.page_layouts) controller.state.page_layouts = core.auto_layout_pages(panels, controller.state.page_size, controller.state.story_genre, "") controller.state.workflow.current_step = .Layout controller.active_screen = .Layout return false, fmt.aprintf("layout generated (%d pages)", len(controller.state.page_layouts)), shared.ok() } project_path, quick_all_format, quick_all_export_path, quick_all_pages, is_quick_local_all, qaerr := parse_quick_local_all_command(input) if !shared.is_ok(qaerr) { return false, "", qaerr } if is_quick_local_all { if err := run_quick_local_pipeline(controller, quick_all_format, quick_all_export_path, quick_all_pages); !shared.is_ok(err) { return false, "", err } err = adapters.save_project(project_path, controller.state) if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("quick local all saved %s and exported %s (%d pages)", project_path, quick_all_export_path, quick_all_pages), shared.ok() } quick_format, quick_path, quick_pages, is_quick_local, qerr := parse_quick_local_command(input) if !shared.is_ok(qerr) { return false, "", qerr } if is_quick_local { if err := run_quick_local_pipeline(controller, quick_format, quick_path, quick_pages); !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("quick local exported %s (%d pages)", quick_path, quick_pages), shared.ok() } export_format, export_path, is_export, exerr := parse_export_command(input) if !shared.is_ok(exerr) { return false, "", exerr } if is_export { opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90} err = adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts) if !shared.is_ok(err) { controller.state.workflow.error_message = err.message return false, "", err } controller.state.export_format = export_format controller.active_screen = .Export controller.state.workflow.current_step = .Complete controller.state.workflow.error_message = "" return false, fmt.aprintf("exported %s", export_path), shared.ok() } if strings.has_prefix(input, "set idea ") { v := strings.trim_space(input[len("set idea "):]) controller.state.story_idea = fmt.aprintf("%s", v) return false, fmt.aprintf("story idea updated"), shared.ok() } if strings.has_prefix(input, "set genre ") { v := strings.trim_space(input[len("set genre "):]) controller.state.story_genre = fmt.aprintf("%s", v) return false, fmt.aprintf("story genre updated"), shared.ok() } if strings.has_prefix(input, "set audience ") { v := strings.trim_space(input[len("set audience "):]) controller.state.target_audience = fmt.aprintf("%s", v) return false, fmt.aprintf("target audience updated"), shared.ok() } if strings.has_prefix(input, "saveas ") { path := strings.trim_space(input[len("saveas "):]) err = adapters.save_project(path, controller.state) if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("saved to %s", path), shared.ok() } if strings.has_prefix(input, "save ") { path := strings.trim_space(input[len("save "):]) err = adapters.save_project(path, controller.state) if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("saved to %s", path), shared.ok() } if strings.has_prefix(input, "open ") { path := strings.trim_space(input[len("open "):]) loaded, lerr := adapters.load_project(path) if !shared.is_ok(lerr) { return false, "", lerr } core.dispose_state(&controller.state) controller.state = loaded controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) return false, fmt.aprintf("loaded from %s", path), shared.ok() } if strings.has_prefix(input, "load ") { path := strings.trim_space(input[len("load "):]) loaded, lerr := adapters.load_project(path) if !shared.is_ok(lerr) { return false, "", lerr } core.dispose_state(&controller.state) controller.state = loaded controller.active_screen = ui.screen_from_workflow(controller.state.workflow.current_step) return false, fmt.aprintf("loaded from %s", path), shared.ok() } if strings.has_prefix(input, "start ") { name := strings.trim_space(input[len("start "):]) job_type, ok := job_type_from_name(name) if !ok { return false, "", shared.validation_error("unknown job type") } id := ui.start_background_job(controller, job_type, "job") _ = ui.mark_job_running(&controller.jobs, id) last_job_id^ = id return false, fmt.aprintf("started job %d", id), shared.ok() } if strings.has_prefix(input, "progress ") { v := strings.trim_space(input[len("progress "):]) n, ok := strconv.parse_int(v) if !ok { return false, "", shared.validation_error("invalid progress") } if n < 0 || n > 100 { return false, "", shared.validation_error("progress must be 0..100") } ui.set_generation_progress(controller, f32(n)) controller.state.workflow.is_generating = true return false, fmt.aprintf("progress set to %d", n), shared.ok() } if strings.has_prefix(input, "fail ") { if last_job_id^ <= 0 { return false, "", shared.validation_error("no active job") } msg := strings.trim_space(input[len("fail "):]) if len(msg) == 0 { msg = "job failed" } err = ui.finish_background_job(controller, last_job_id^, msg) if !shared.is_ok(err) { return false, "", err } return false, fmt.aprintf("job marked failed"), shared.ok() } return false, "", shared.validation_error("unknown command") } run_tui_loop :: proc(state: ^core.Comic_State) -> (string, shared.App_Error) { controller := ui.new_controller(state^) defer ui.dispose_job_manager(&controller.jobs) last_job_id := 0 for { clear_screen() fmt.println("comic-odin interactive tui") fmt.println("screens: [1]Story [2]Script [3]Characters [4]Panels [5]Layout [6]Bubbles [7]Export [8]Community") fmt.println("shortcuts: h help | s status | ? doctor | r ready | n next | p plan | x auto | d done | c cancel | q quit") fmt.println() view := ui.render_app_to_string(controller) fmt.println(view) delete(view) fmt.print("\ntui> ") line, ok, rerr := read_stdin_line() if !shared.is_ok(rerr) { return "", rerr } if !ok { break } cmd := normalize_tui_command(line) if len(cmd) == 0 { delete(line) continue } quit, out, cerr := run_tui_command(&controller, cmd, &last_job_id) delete(line) if !shared.is_ok(cerr) { fmt.printf("error: %s\n", cerr.message) } else if len(out) > 0 { fmt.println(out) delete(out) } if quit { break } } core.dispose_state(state) state^ = controller.state controller.state = core.Comic_State{} return "", shared.ok() } run_cli_command :: proc(cmd: Parsed_CLI_Command, state: ^core.Comic_State) -> (string, shared.App_Error) { switch cmd.kind { case .Demo: controller := ui.new_controller(state^) defer ui.dispose_controller(&controller) job_id := ui.start_background_job(&controller, .Generate_Script, "Generating script") _ = ui.mark_job_running(&controller.jobs, job_id) ui.set_generation_progress(&controller, 35) _ = ui.finish_background_job(&controller, job_id, "") return ui.render_app_to_string(controller), shared.ok() case .Status: controller := ui.new_controller(state^) defer ui.dispose_controller(&controller) return ui.render_app_to_string(controller), shared.ok() case .Save: if len(cmd.path) == 0 { return usage_text(), shared.validation_error("missing save path") } err := adapters.save_project(cmd.path, state^) if !shared.is_ok(err) { return "", err } return fmt.aprintf("Saved project to %s", cmd.path), shared.ok() case .Load: if len(cmd.path) == 0 { return usage_text(), shared.validation_error("missing load path") } loaded, err := adapters.load_project(cmd.path) if !shared.is_ok(err) { return "", err } core.dispose_state(state) state^ = loaded return fmt.aprintf("Loaded project from %s", cmd.path), shared.ok() case .Tui: return run_tui_loop(state) case .Gui: err := gui.run_gui_app(state) if !shared.is_ok(err) { return "", err } return fmt.aprintf("GUI session ended"), shared.ok() case .Help: fallthrough case: return usage_text(), shared.ok() } } run_cli_from_process_args :: proc(state: ^core.Comic_State) -> (string, shared.App_Error) { if len(os.args) <= 1 { return run_cli_command(Parsed_CLI_Command{kind = .Demo}, state) } cmd := parse_cli_command(os.args[1:]) return run_cli_command(cmd, state) }