comic/odin/src/app/cli.odin
2026-05-28 15:20:02 +02:00

1182 lines
39 KiB
Odin

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 <path>|load <path>|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 <pdf|png|cbz> <path>, auto all local <pdf|png|cbz> <path> [pages], new, set idea <text>, set genre <text>, set audience <text>, generate script [pages], generate script local [pages], generate panels [page <n>], generate panels local [page <n>], layout auto, export <pdf|png|cbz> <path>, quick local <pdf|png|cbz> <path> [pages], quick local all <project_path> <pdf|png|cbz> <export_path> [pages], goto <screen> (or 1..8), step <workflow>, save|saveas <path>, load|open <path>, start <script|character|panel|export>, progress <0-100>, done|d, fail <message>, 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..<pages {
chars_present := make([]string, 1)
chars_present[0] = "char_001"
dialogue := make([]core.Dialogue, 1)
dialogue[0] = core.Dialogue{speaker_id = "char_001", text = "Let's do this.", bubble_type = .Normal, emotion = .Neutral}
panel := core.Panel{
panel_id = local_panel_id_by_index(i),
panel_number = 1,
shot_type = .Medium,
description = story_idea,
characters_present = chars_present,
dialogue = dialogue,
caption = "",
sound_effects = nil,
transition_from_previous = .None,
}
panels := make([]core.Panel, 1)
panels[0] = panel
append(&out_pages, core.Page{page_number = i + 1, layout_type = .Grid, panels = panels})
}
chars := make([]core.Character, 1)
chars[0] = core.Character{id = "char_001", name = "Protagonist", role = .Protagonist, description = "Main character"}
return core.Comic_Script{title = "Local Script", synopsis = story_idea, characters = chars, pages = out_pages[:]}
}
parse_generate_panels_page :: proc(input: string) -> (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 <pdf|png|cbz> <path>")
}
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 <pdf|png|cbz> <path> [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 <project_path> <pdf|png|cbz> <export_path> [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 <pdf|png|cbz> <path>")
}
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 <pdf|png|cbz> <path> [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)
}