Skip to content

fix for windows. - #13

Open
xuhongming251 wants to merge 1 commit into
Soul-AILab:mainfrom
xuhongming251:main
Open

fix for windows.#13
xuhongming251 wants to merge 1 commit into
Soul-AILab:mainfrom
xuhongming251:main

Conversation

@xuhongming251

Copy link
Copy Markdown

fix for windows.
add time log.

add time log.
@jokerz0624

Copy link
Copy Markdown
Collaborator

Thanks very much for your commit. But too many logs is not a good idea. Can you edit this pr and just remain your bug-fix for Windows ?

jack-buzzni referenced this pull request in buzzni/SoulX-FlashTalk Apr 26, 2026
…se 0–2c.4) + shadcn migration (#7)

* feat(db): PR0 local mongod + dev seed + integration plan

Set up the foundation for attaching FlashTalk Studio to the ai_showhost
MongoDB cluster. PR0 ships only local-dev infrastructure + the locked plan;
no app behavior changes yet.

- scripts/dev_mongod.sh: start/stop/status/logs helper for the user-local
  MongoDB Community 7.0 tarball install at ~/local/mongodb-community
- scripts/_lib.py: assert_local_only(url, db_name) guard refuses any non-
  localhost URL or non-ai_showhost DB name; record_migration() appends to
  studio_migrations as an audit trail (per plan decision #13)
- scripts/seed_dev_db.py: idempotent upsert of jack/testuser/noaccess with
  bcrypt $2b$12 hashes (same format prod users.hashed_password already uses)
- docs/db-integration-plan.md: 16 locked decisions after /plan-eng-review +
  2 codex rounds (12 prior findings + 5 new, all resolved or risk-accepted)
- requirements.txt: pymongo, motor, bcrypt, PyJWT

Verified bcrypt round-trip, re-seed idempotency (users stable, audit appends),
guard refusal of prod URL/DB-name, and mongod stop/start cycle preserving data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(db): PR1 motor connection + user_repo + 006 migration

Add the async motor singleton, the read-only user repository, and the
subscriptions backfill migration. PR1 lands the DB layer; no app behavior
changes yet beyond a startup-time ping (fail-fast on unreachable mongod
per plan decision #15).

- modules/db.py: AsyncIOMotorClient singleton with init/close hooks. init()
  pings mongod and creates all studio_* indexes including the partial
  unique index {user_id, step} where status='selected' that enforces
  "at most one selected per (user, step)" at the DB layer (decision #11).
- modules/repositories/user_repo.py: read-only find_by_id +
  has_subscription, plus bump_studio_token_version for use by auth.logout
  (PR2). Never touches platform's token_version.
- scripts/studio_006_add_subscriptions.py: per-record upsert for
  subscriptions + studio_token_version backfill, idempotent at the record
  level (no outer guard — decision #13). studio_migrations row is
  append-only audit. --dry-run is the default; --commit applies.
- config.py: MONGO_URL, DB_NAME, STUDIO_JWT_SECRET, STUDIO_JWT_TTL_DAYS.
- app.py: startup hook calls db.init() before queue worker starts;
  shutdown hook calls db.close().
- tests: 16 new tests covering connection, index idempotency, partial
  unique enforcement, user_repo, and 006 dry-run/commit/idempotency.
  All 135 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(seed): drop jack from seed_dev_db so prod-shaped record survives re-runs

The dev DB holds the real prod-shaped jack record (real bcrypt hash,
role="admin", hashkey, refresh_token_hashes, token_version=35) so the
operator can log into studio with the actual prod password. Re-running
seed_dev_db.py used to clobber that record with a synthetic dev1234
hash. Now seed_dev_db.py only touches testuser/noaccess; jack is
preserved across re-runs.

Verified: re-seeded twice, jack hash tail K7vhTftNvrLZqIe unchanged,
role=admin unchanged, token_version=35 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): PR2 studio JWT login, /login page, queue user_id

End-to-end studio authentication. Independent of platform: bcrypt-verifies
the password against users.hashed_password and issues a studio-only JWT
signed with STUDIO_JWT_SECRET. Logout bumps users.studio_token_version,
never touches platform's token_version.

Backend
- modules/auth.py: login/logout/me + JWT verify + auth_middleware
- app.py: /api/auth/{login,logout,me} routes, ASGI middleware after CORS,
  Request param + user_id wired into /api/generate, /api/generate-conversation,
  /api/queue, /api/queue/{task_id} DELETE, /api/progress/{task_id},
  /api/tasks/{task_id}/state, /api/results/{task_id} (owner-scoped, admin/master sees all)
- modules/task_queue.py: enqueue requires user_id, persisted to JSON; legacy
  ownerless entries skipped on load (decision #9); cancel_task returns
  ok/not_found/forbidden; get_status filters by user_id
- Public allow-list: /, /api/config, /api/files/*, /api/videos/*, /api/auth/login
- Per-request subscription re-check (decision #5): admin pull → next
  request returns 403 in real time
- Per-product token_version (decision #6): studio_token_version separate from
  platform's token_version

Frontend
- routes/LoginPage.tsx: separate /login route (not modal); ?next= roundtrip
- routes/RequireAuth.tsx: client-side guard with redirect-with-next
- stores/authStore.ts: localStorage token + user, registers fetchJSON's
  Authorization provider on import, hooks 401/403 → redirect to /login
- api/http.ts: setUnauthorizedHandler slot, 401/403 fires it (skip
  /api/auth/login itself so wrong-password stays a normal user error)
- App.tsx: /login route + RequireAuth on every protected page
- main.jsx: side-effect import of authStore so the provider is registered
  before any fetchJSON call

Tests
- tests/conftest.py: autouse fixture monkey-patches the middleware to inject
  a fake user, so the existing 19 test_api_*.py files keep passing without
  per-call Authorization headers. Real-auth tests opt out by file name or
  @pytest.mark.real_auth.
- tests/test_auth_login.py: 9 happy + sad paths (wrong pw, no studio sub,
  inactive, unapproved, empty creds, login→me, login→logout→revoked)
- tests/test_auth_current_user.py: 6 cases for the middleware (public path,
  missing/malformed Authorization, tampered token, valid token, mid-session
  subscription revocation → 403)

Verified: 150/150 backend pytest pass (135 prior + 15 new), 0 TS errors.
Live curl flow: login OK, wrong pw 401, no-studio-sub 401, tampered 401,
logout → old token 401 ("token revoked").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(storage): PR3 LocalDiskMediaStore + bucket-prefixed storage_key

Add the media storage abstraction the rest of the app will write through
once PR4/PR5 land. The DB never stores absolute paths; it stores
storage_key — a bucket-prefixed relative key like
"outputs/hosts/saved/host_x_s42.png" or "uploads/ref_img_abc.png". A
future cloud impl swaps url_for() to return presigned URLs.

- modules/storage.py: MediaStore protocol + LocalDiskMediaStore impl with
  save_bytes / save_path / local_path_for / url_for / delete. The kind
  argument routes to a bucket+subpath; it never appears in the key.
- local_path_for partitions on the first '/' and joins the *remainder*
  with the bucket dir — joining the full key would double-apply the
  bucket dir (codex finding #N2). Strict traversal rejection: any '..'
  segment, empty segment, or unknown bucket raises ValueError.
- resolve_legacy_or_keyed() lets the file-serving handler accept BOTH
  old (no-bucket) and new (bucket-prefixed) URLs without breaking any
  currently-stored result manifest URL.
- app.py /api/files/{filename:path}: drops the SAFE_ROOTS probe loop in
  favor of resolve_legacy_or_keyed; safe_upload_path stays as the final
  realpath-containment guard.

PR4/PR5 will refactor write callers (uploads, host generation, results
JSON) to use media_store.save_*. PR3 ships only the abstraction + the
read-side compatibility, so no response shape changes.

17 new tests cover save roundtrips, key resolution, traversal rejection,
unknown buckets, double-apply regression. Live: legacy URL 200, bucket
URL 200, traversal 404. Full suite: 167 passed (150 prior + 17).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(queue): one-shot script to backfill pre-PR2 queue entries with user_id

After PR2 introduced required user_id on queue entries, the _load filter
drops any entry missing it. On a single-operator dev box every legacy
task came from one person, so the safe move is to tag those entries with
that user_id rather than lose them on the next _save.

Idempotent: only touches entries lacking user_id. Backs up the queue
file to .json.bak before writing. dry-run is the default mode.

Used once: 11 legacy entries (all jack's) tagged on this dev box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(repo): PR4 step 1 — studio_host_repo + studio_saved_host_repo

Two repository modules backing the host-data cutover. Both async, both
take user_id as the first arg and scope every query.

studio_host_repo (candidate hosts under lifecycle state machine):
- get_state / find_by_image_id / upsert_candidate
- record_batch — multi-upsert as drafts (uses media_store.key_from_path)
- cleanup_after_generate — demote prev selected to draft+is_prev_selected,
  delete stale drafts and pre-existing prev markers
- select — demote-then-promote with single retry on partial-unique-index
  race (decision #11)
- commit — selected → committed, dedupe video_id, delete other non-committed
- delete_candidate ("deleted" | "not_found" | "committed")
- cascade_delete_by_video — orphan removal when video_ids becomes empty
- ASCII state-machine docstring at the top, kept in sync with lifecycle.py:6-38

studio_saved_host_repo (user library):
- create — upsert by (user_id, host_id), preserves created_at on second call
- list_for_user — sorted by created_at desc
- get / delete — owner-scoped, file is best-effort cleaned up after row

storage.py additions:
- key_from_path() — converts an absolute disk path written by the legacy
  generator into a bucket-prefixed storage_key; used by record_batch and
  the upcoming studio_007 import.

27 new tests cover state transitions, partial-unique enforcement,
file-row coupling on delete, cleanup demotion, cascade orphan removal,
user_id isolation. Full suite: 194 passed (167 prior + 27).

Lifecycle.py + /api/hosts cutover lands in subsequent PR4 commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): PR4 step 2 — /api/hosts saved-host CRUD on DB

Cut /api/hosts CRUD over from on-disk JSON sidecars to studio_saved_hosts.

- list_saved_hosts: studio_saved_host_repo.list_for_user(user_id)
- save_host: media_store.save_path("hosts", source) → key, then create()
  with rollback on DB failure (file deletion)
- delete_host: studio_saved_host_repo.delete(user_id, host_id) — owner-scoped,
  row + file removed; legacy on-disk helpers dropped
- All three endpoints now take request: Request and pull user from
  request.state.user

Test infrastructure
- conftest auto-bypass fixture now redirects MONGO_URL/DB_NAME to a
  per-worker test DB (`ai_showhost_test_<worker>_apitests`) and drops
  studio_*/users between tests, so api-level tests don't pollute the
  dev `ai_showhost`. Repo tests + auth tests opt out by file name.
- 4 test_api_*.py fixtures (hosts, composite_generate, elevenlabs_generate,
  host_generate) switched to `with TestClient(app) as c: yield ...` so
  FastAPI startup hooks (db.init) actually fire.

Full suite: 194 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): PR4 step 3 — lifecycle cutover from .meta.json sidecars to DB

Replace every modules.lifecycle call with the studio_host_repo equivalent.
Public API surface and event/response shapes are preserved; the only
visible change is that records now live in studio_hosts (DB) rather than
*.png.meta.json sidecars on disk.

Changes
- task_queue worker passes user_id from the queue entry to the registered
  handler so generate_video_task / generate_conversation_task can attribute
  the lifecycle commit to the right user.
- generate_video_task: lifecycle.commit("host"|"composite", task_id) →
  await host_repo.commit(user_id, "1-host"|"2-composite", task_id).
  Pre-PR2 tasks lacking user_id skip the commit (logged) — data integrity
  recovered later by the studio_007 import.
- /api/host/generate/stream — request: Request added; record_batch /
  cleanup_after_generate / get_state are now awaited host_repo calls under
  step="1-host". prev_selected comes back already serialized (lifecycle's
  serialize_record removed everywhere).
- /api/composite/generate (regular) and /api/composite/generate/stream —
  same pattern, step="2-composite". batch_id is generated inline via
  uuid.uuid4().hex[:8] (lifecycle.new_batch_id was the same shape).
- /api/host/select, /api/composite/select — request param added; map to
  await host_repo.select(user_id, "1-host"|"2-composite", image_id).
  FileNotFoundError → LookupError (repo's contract).
- /api/composites/{image_id} DELETE — owner-scoped via host_repo.
- /api/videos/{task_id} DELETE — cascade_delete_by_video now takes user_id;
  scoped to that user's committed rows.

194 tests still pass; no regressions. Live: backend starts, /api/hosts
returns the empty studio_saved_hosts (007 will populate), /api/queue
shows the 11 backfilled jack entries.

modules/lifecycle.py is now unused by the API path. Kept on disk for
studio_007 migration to read sidecar metadata one last time, then
removable in a follow-up cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(migrate): PR4 step 4 — studio_007_local_import (hosts portion)

One-shot import of legacy on-disk host artifacts into studio_hosts and
studio_saved_hosts. Per plan §8.3 + decision #13, idempotency is
record-level (upsert by natural key). studio_migrations row is appended
at the end as an append-only audit trail; running twice yields two rows.

Codex N4 (pre-scan demote): if multiple sidecars claim status='selected'
for the same (user_id, step), keep the most-recently committed/generated
and demote the rest to 'draft' BEFORE the upsert, otherwise the
partial-unique index `one_selected_per_step` rejects the second writer.

Walks
- outputs/hosts/saved/host_*.png.meta.json   → studio_hosts (step="1-host")
- outputs/composites/composite_*.png.meta.json → studio_hosts (step="2-composite")
- outputs/hosts/saved/<uuid32>.json (no .meta. infix) → studio_saved_hosts

Path normalization: every absolute path is converted to a bucket-prefixed
storage_key. Symlinked variants (jack/, justin/) are recognized and
stripped. Unknown-bucket paths are skipped + logged.

scripts/_lib.py: assert_local_only regex relaxed to accept multi-segment
test DB names (e.g. ai_showhost_test_main_007).

6 new tests cover dry-run, candidate import, duplicate-selected demotion,
re-run idempotency, saved-host sidecar import, prod URL refusal.

Live run on dev DB: 163 candidates imported (101 hosts + 62 composites);
153 committed, 8 draft, 2 selected (one per step — invariant holds).
0 saved hosts on this box. Full suite: 200 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(repo): PR5 results cutover — studio_result_repo + 007 results portion

Cut every result-manifest read/write over to studio_results, retire the
outputs/results/<task>.json + outputs/video_history.json files.

modules/repositories/studio_result_repo.py
- upsert / get / list_completed / delete — all owner-scoped
- find_by_task_id (no user filter) for the public /api/videos/{task_id}
  GET endpoint where <video> tags can't send Authorization headers
  (decisions #6, #10)

app.py cutover
- generate_video_task / generate_conversation_task: write the manifest to
  studio_results.upsert(user_id, …) instead of an outputs/results/<task>.json
  sidecar. video_storage_key is set from media_store.key_from_path(output).
  add_to_history is gone — history is now a query.
- /api/history: studio_result_repo.list_completed(user_id) projected back
  into the legacy `videos` shape so the SPA's existing parser keeps working.
- /api/videos/{task_id} GET: studio_result_repo.find_by_task_id (public).
- /api/results/{task_id} GET: owner-scoped get + queue-snapshot fallback for
  in-flight tasks. Admin/master sees any user's manifest.
- /api/videos/{task_id} DELETE: cleans up the row via studio_result_repo.delete.
- Dead helpers removed: load_video_history, save_video_history,
  add_to_history, _write_result_manifest, VIDEO_HISTORY_FILE.

scripts/studio_007_local_import.py — results portion
- Walks outputs/results/*.json, scrubs every absolute path through
  _scrub_paths into bucket-prefixed storage_keys (codex N5/decision #16),
  upserts into studio_results by (user_id, task_id).
- Migration name unified to "studio_007_local_import" (was
  "studio_007_local_import_hosts" pre-PR5); summary now reports
  hosts/saved/results/demoted/skipped counts.

Tests
- tests/test_studio_result_repo.py: 16 cases covering upsert/get/list/delete,
  user_id scoping, public find_by_task_id, the partial-unique blocker.
- tests/test_studio_007_local_import.py: new test_imports_result_manifests
  verifying path scrubbing on params/meta/video_path.
- conftest opt-out adds the new test file so it bypasses the auto-bypass
  fixture.

Live run on dev DB: 4 result manifests imported. /api/history returns 4
videos, /api/results/<id> returns 200 with the manifest, /api/videos/<id>
HEAD serves video/mp4. Full suite: 217 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(cleanup): drop modules/lifecycle.py and scripts/migrate_lifecycle.py

PR4 step 3 cut every caller of modules/lifecycle.py over to
studio_host_repo. The file has been dead since then; remove it.

scripts/migrate_lifecycle.py was the one-shot that tagged pre-existing
host/composite candidates as committed-orphan (sidecar status field).
That migration ran once at lifecycle's introduction; studio_007 now
owns the equivalent journey from sidecars to studio_hosts. Remove it
along with its only import target.

Stale "mirrors lifecycle.py" docstrings in studio_host_repo /
studio_result_repo updated to stand on their own.

482 lines of dead code retired. Full suite: 217 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): home + mypage + results-list + profile dropdown

Replaces the wizard-auto-redirect at / with a real home, adds two
standalone pages (mypage, results), and exposes the profile / logout
controls in a top-right dropdown.

- routes/ProfileMenu.tsx: avatar + display_name chip; dropdown opens on
  click, closes on outside-click or ESC. Items: 마이페이지 / 내 영상들 /
  로그아웃. Uses authStore's subscribe() so it re-renders after login,
  /me refresh, or logout from another tab.
- routes/AppHeader.tsx: minimal header (brand + ProfileMenu) for the
  non-wizard pages. The wizard's TopBar gets the same ProfileMenu
  appended after the existing reset button.
- routes/HomePage.tsx: two big buttons — make a video (→ /step/1) and
  browse past results (→ /results). Greeting line if logged in.
- routes/MyPage.tsx: id / name / role / subscriptions / video count
  (queries /api/history) + logout button. Future: password change.
- routes/ResultsListPage.tsx: grid of completed renders from
  /api/history. Each card uses the public /api/videos/<id> as a muted
  preview thumb and links to /result/<id>. Empty + error states.
- App.tsx: /, /mypage, /results all RequireAuth-wrapped. Drop the
  legacy RootRedirect helper (was the only auto-jump-into-wizard path).

Live: http://localhost:5555/ now lands on the home; profile dropdown
shows on the wizard / mypage / results headers; logout flow works
end-to-end (token bump → next request 401 → /login redirect).
TS clean, 217 backend tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: playlist feature plan (eng + codex review CLEARED)

13 locked decisions, 14 codex findings folded in (10 inline-fixed,
3 disagreements analyzed and resolved-as-designed, 1 deferred).
Ready for implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(repo): playlist lane A — studio_playlist_repo + cascade helper + indexes

Lane A of docs/playlist-feature-plan.md. New per-user studio_playlists
collection with NFC+casefold+strip name uniqueness; clear_playlist_id added
to studio_result_repo so the cascade is testable in isolation before Lane B
(endpoints) and Lane C (result_repo extensions) build on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): playlist lane B — 6 endpoints + history filter + generate Form param

Lane B of docs/playlist-feature-plan.md. GET/POST /api/playlists,
PATCH/DELETE /api/playlists/{id}, PATCH /api/results/{task_id}/playlist,
playlist_id query param on /api/history, optional playlist_id Form param on
/api/generate and /api/generate-conversation. Pulls set_playlist and the
list_completed playlist_id filter forward from Lane C so Lane B's endpoints
have the result_repo helpers they need.

Plan-aligned semantics: dup name → 409, reserved/empty → 400, missing or
cross-user → 404. /api/history with unknown playlist_id returns 200 [] per
decision #12 (filter UI must survive a stale id).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(worker): playlist lanes C+D — upsert silent-coerce + worker passthrough

Lanes C+D bundled — both worker-side. studio_result_repo.upsert silently
coerces a stale playlist_id to null on miss/cross-user (plan §9 race recovery
when the user deletes a playlist mid-render); generate_video_task and
generate_conversation_task accept playlist_id and forward it into the
manifest so /api/generate's choice survives the render.

The PATCH endpoint still raises 404 — strict-vs-tolerant split between user
intent (PATCH) and worker recovery (upsert) is intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): playlist lane E — Step 3 picker + /results sidebar + card move

Lane E of docs/playlist-feature-plan.md. New api/playlists.ts wrapper
(list/create/rename/delete + moveResultToPlaylist). Step 3 gains a
PlaylistPicker card that gracefully degrades to "이번 영상은 미지정으로
저장됩니다" if the list fetch fails (plan decision #13). /results gets a
two-pane layout with a 전체 / 미지정 / playlists sidebar (alphabetical sort
in JS, plan decision #11), inline create + hover [⋯] rename/delete with
cascade-aware confirm copy, and per-card [⋯] popover to move videos
between playlists.

Browser-verified end-to-end: login → create from sidebar → filter switch
→ rename → empty-state copy. API CRUD smoke-tested via curl against the
restarted backend (dup → 409, reserved → 400, unknown filter → 200 []).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ui): adopt unified design system (Tailwind v4 + #005DFF brand)

User-supplied global.css adopted in frontend/src/index.css — HSL design
tokens (--primary #005DFF, --secondary #F0F7FF, --accent #C0D8FF,
--sidebar-*, etc.), Pretendard font, Tailwind v4 utilities, custom
panel-glass / surface-base / panel-row / icon-btn-float helpers.

Wizard tokens.css bridged to the global system: .studio-root tokens
(--bg, --accent, --border, --text, ...) now point at the new HSL tokens
so all wizard components (Step1/2/3 + primitives) inherit the new brand
palette with zero per-component changes. Semantic green/amber kept as
oklch for hue distinctions; --danger now bridges to --destructive.

Refactored 7 inline-CSSProperties pages to Tailwind classes:
- LoginPage, HomePage, MyPage — full hero/dashboard treatment
- AppHeader — broken `.topbar` class (scoped to .studio-root, never
  matched outside the wizard) replaced with proper Tailwind
- ProfileMenu, ResultsListPage, PlaylistPicker

Visual fixes caught in audit:
- AppHeader was rendering with default browser layout because its
  .topbar class lived inside .studio-root scope
- /results sidebar disappeared into the page bg (both --secondary);
  page now uses --background so sidebar's --sidebar-background pops
- 47 hardcoded hex colors (#3553ff, #b00020, #f7f7fa, etc.) replaced
  with semantic tokens

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): F-001 — apply Pretendard to body so Korean type renders correctly

The CDN @import loaded the font and @theme inline registered --font-sans, but
no element actually consumed it: body inherited ui-sans-serif, system-ui from
browser defaults. Hangul rendered with the wrong glyph shapes — wrong product.
Declare the font stack directly on html/body so the global cascade picks up
Pretendard Variable and every page benefits.

Verified via getComputedStyle(document.body).fontFamily before/after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): F-002 — /results responsive fix, sidebar stacks on mobile

The grid template was hardcoded to a 2-column layout regardless of viewport,
which left the main content area ~120px wide at 375px (text wrapped 1-2
characters per line — unreadable). Switched to grid-cols-1 by default,
md:grid-cols-[minmax(220px,240px)_minmax(0,1fr)] above the md breakpoint.
Sidebar's sticky positioning is now md:-only so it doesn't pin the top
of the page on mobile. Card grid template also moved from inline style to
Tailwind arbitrary value for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): F-003 — replace 📁 emoji with proper SVG icons on home

The 📁 emoji on /내 영상들 was AI-slop pattern #7 (emoji as design element)
and clashed with the rest of the iconography (custom SVG brand mark, custom
sidebar [⋯] glyph). Both home action buttons now use the project's Icon
component — a "plus" for 영상 만들기 and a new "folder" path on 내 영상들 —
inside a consistent rounded-square icon-box treatment that mirrors the
brand mark's silhouette.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): F-004 — header brand link + profile chip hit 44px touch target

The header brand link's hit area was 22px tall (the height of the brand
content). The ProfileMenu chip was 38px. Both fall below the 44px iOS HIG /
WCAG mobile touch target. Added inline-flex + min-h-[44px] + horizontal
padding to expand the hit areas without enlarging visual chrome — the brand
mark and chip still look the same, the click region just grew.

Verified empty array on getBoundingClientRect() < 44 audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): F-005+F-006 — home greeting promoted, card no longer stretches

Combined two tightly-coupled findings into one commit because they touch the
same file and one without the other looks incomplete:

F-006: The greeting "안녕하세요, [name] 님" was a tiny gray label above the
"무엇을 할까요?" headline. Personal touchpoint, treated like a footnote.
Merged into a single H1 — "{name}님, 무엇을 만들어볼까요?" — that's both
personal and actionable. Subtitle adds context without competing.

F-005: Main was `flex items-center` which stretched the card vertically
to fill the viewport (cross-axis default stretch). Card was 550px tall for
~250px of content. Switched to `items-start` and `pt-12 md:pt-20` so the
card is its natural size, sits near the top, and the empty space below
is honest negative space (the page genuinely is empty for now) rather
than a ghost-shaped void inside the card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): tighten non-wizard pages to wizard shape language (compact mode)

Audit pass 2 found that color tokens were unified but shape primitives
(radius, padding, shadow, button height) were still two different systems:
- Wizard: r-lg 12px, --pad-card 20px, shadow-xs subtle 1px, --row-h 36px
- My non-wizard: rounded-2xl 16px, p-10 40px, 0_4px_24px shadow, h-12 48px

The wizard is the product's main creation surface — it sets the density.
Non-wizard pages were doing a marketing-site spacious treatment that
clashed when navigating between /step/1 → / → /results.

Aligned everything to wizard density:
- surface-base utility now bakes in r-lg radius + shadow-xs
- HomePage card p-10→p-6, h1 28px→20px, ActionButton tile padding tightened,
  decorative drop-shadows removed
- LoginPage card r-lg→r-12, p-8→p-6, inputs h-9 (wizard --row-h),
  submit h-10
- MyPage card p-8→p-5, rows tighter, logout h-9
- ProfileMenu chip h-44 (visual)→h-9 (matches wizard buttons), still
  pill-shaped (rounded-full) which is the wizard's chip pattern
- ResultsListPage sidebar rows h-8 (matches wizard segment density),
  result card padding p-3→p-2.5, text scale text-sm→text-[13px]

The chip-shape ProfileMenu trade: F-004 fix bumped touch target to 44px,
but that broke shape parity with wizard. Compromise — visual chrome 36px
(matches wizard --row-h), accepts the smaller mobile tap that the rest
of the wizard already accepts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): add vertical rhythm to wizard .card so children breathe

User caught: on /step/1, "변동성" → "이미지 품질" → "쇼호스트 만들기"
button row sections all stacked with zero gap between them. Same on every
wizard card — .card class had padding but no gap, and individual children
(Field, hr, raw divs) didn't add their own margin. Result: dense workspace
that read as cramped instead of compact.

Fix:
- .card now sets `display: flex; flex-direction: column; gap: var(--gap-row)`
  so every child stacks with 14px breathing room
- removed redundant `.card-header { margin-bottom: 14px }` (the new card
  gap handles it)
- removed `.tabs { margin-bottom: 14px }` (same reason — was double-spacing
  inside cards on /step/3)
- removed inline `style={{ marginBottom: 14 }}` from Step1Host's segmented
  row (was paired with the .card-header margin, now redundant)

Effect: every wizard card on every step now has consistent 14px vertical
rhythm between its children — Field+Field, segment+helper-row, tabs+content.
Spacing scale matches --gap-row token, no more magic numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): remove redundant inline margins now that .card has gap

Follow-up to the .card gap fix. Several components had inline marginBottom/
marginTop sized to compensate for the missing card gap — now they double
up. Cleanup:
- HostControls error msg: marginBottom: 10 removed (card gap handles it)
- Step3Audio error msg: marginTop: 10 removed (same)
- Step3Audio "음성 준비 완료" row: mt-3 className removed (same)

No visual regression — same spacing comes from the parent .card now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): .tab now inline-flex so icon + label sit on one row

User caught: the 3 wizard tabs at top of Step 3 (목소리 고르기 / 내 목소리 복제
/ 녹음 파일 업로드) had their icon stacked above the label instead of beside
it. Cause: .tab had no display rule, so the button defaulted to inline-block
and when content was tight (Korean text + icon = ~90px, button width ~92px)
the contents wrapped to two lines making it look like a column layout.

Fix: .tab is now `display: inline-flex; align-items: center; gap: 6px;
white-space: nowrap`. Icon and label are explicit flex siblings on one row,
no wrap. Removed the inline `marginRight: 5px` + `verticalAlign: -2px` on
the Icon since flex `gap` handles spacing now and align-items handles the
vertical alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): all-in shadcn migration — phases A+B+C1 (primitives shim)

Phase A: shadcn setup
- Installed class-variance-authority, clsx, tailwind-merge, lucide-react,
  @radix-ui/react-slot
- components.json with @/* alias, Tailwind v4 + new-york style + Lucide icons
- src/lib/utils.ts cn() helper
- Vite alias for @/* → ./src/*

Phase B: 19 shadcn primitives installed via CLI
  alert, badge, button, card, checkbox, collapsible, dialog, dropdown-menu,
  input, label, popover, radio-group, select, slider, tabs, textarea,
  toggle, toggle-group, tooltip

Phase C1: studio/primitives.jsx rewritten as shadcn shim
- Same wrapper API as before (Card, Button, Badge, Modal, Slider, Segmented,
  Chip, Field, UploadTile) so all 20 call sites keep working unchanged
- Underneath:
  - Button → shadcn Button (variant: primary/secondary/ghost/danger
    mapped to default/outline/ghost/destructive)
  - Badge → shadcn Badge (with custom variants for success/warn that
    shadcn doesn't ship)
  - Card → shadcn Card with composed Header/Title/Description/Content
    sub-components
  - Modal → shadcn Dialog (gets focus trap, scroll lock, portal for free)
  - Slider → shadcn Slider (Radix's battle-tested pointer/touch handling
    replaces our 40-line DIY drag logic)
  - Segmented → shadcn ToggleGroup (single-select)
  - Chip → custom Tailwind (shadcn Toggle is rectangular, we want pill)
  - Field, UploadTile → kept custom (no shadcn equivalent at this density)

Visual parity verified on /step/1, /step/3, /render, /home — all wizard
cards, segmented controls, action buttons, and step pills render the same
or better. Korean Pretendard intact. Primary blue #005DFF unchanged.

What's NOT done yet (next commits):
- C2: PlaylistPicker still uses native <select> — will swap to shadcn Select
- C2: Step 3 tabs still use .tab CSS class — will swap to shadcn Tabs
- C3: ProfileMenu DIY popover, /results card [⋯] popover, sidebar item [⋯]
  popover — will swap to shadcn DropdownMenu

yarn.lock removed (npm-only project; shadcn CLI was choking on the stub).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): Phase C2 — PlaylistPicker → shadcn Select, Step3 tabs → shadcn Tabs

Two long-running visual papercuts addressed:

PlaylistPicker (the user's primary complaint):
- Was a native <select>. OS-default dropdown chrome on every browser.
  Pretendard ignored. Korean text clipped vertically by 1-2px on Chrome/
  Safari. No keyboard nav beyond browser default. No focus ring.
- Now shadcn Select (Radix UI). Custom dropdown panel with Pretendard,
  border-border, primary blue selection, lucide chevron. "+ 새 플레이리스트
  만들기" sits below a SelectSeparator and uses the primary color so it
  reads as a CTA, not just another option. Replaced the AlertCircle warn
  state with lucide-react icons too — consistent with the rest of the
  shadcn set.

Step 3 tabs:
- Were `<button className="tab">` rendering with .tab CSS class. Manual
  `voice.source === t.id ? 'on' : ''` toggle. No keyboard arrow nav.
- Now shadcn Tabs (Radix). Controlled mode with voice.source as the
  source of truth. Active state is `data-[state=active]` driven, primary
  blue border-b for the active tab. lucide-react Mic / Copy / Upload
  icons replace the custom Icon SVG paths for these specific 3.

Both changes preserve visual layout — the wizard's existing rhythm and
border-bottom underline pattern are reproduced via Tailwind utility
overrides on the shadcn primitives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): Phase C3 — popovers → shadcn DropdownMenu

Three DIY popover patterns replaced with shadcn DropdownMenu (Radix):
- ProfileMenu (top-right user chip): the menu trigger keeps its pill chip
  shape; menu items render via DropdownMenuItem with proper destructive
  variant on 로그아웃
- ResultsListPage sidebar item [⋯] (rename / delete): hover-revealed
  trigger now uses DropdownMenu, lucide MoreHorizontal icon, destructive
  variant on 삭제
- ResultsListPage card [⋯] (move to playlist): same shadcn pattern, with
  DropdownMenuLabel for the "다른 플레이리스트로 이동" header

Removed ~80 lines of useEffect/useRef boilerplate for outside-click
handling and ESC closing — Radix handles all of that natively. The local
PopoverItem helper component was also removed since DropdownMenuItem
covers it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): Phase D — delete studio/primitives.jsx, consumers import shadcn directly

The wizard's old primitives wrapper file is gone. All 22 consumer files
now import their primitives directly:

shadcn-backed (in @/components/):
  WizardButton  → wraps shadcn Button + variant rename + studio Icon string lookup
  WizardBadge   → wraps shadcn Badge + neutral/accent/success/warn variants
  WizardCard    → wraps shadcn Card + title/subtitle/eyebrow/action API
  WizardModal   → wraps shadcn Dialog + open/onClose/title/footer API
  WizardSlider  → wraps shadcn Slider + single-number + formatted readout

Custom (also in @/components/, no shadcn equivalent):
  Field         → label + hint + child stack
  Chip          → pill toggle button
  Segmented     → ToggleGroup with old options[] API + studio Icon string lookup
  UploadTile    → file dropzone (paste, drag, FileReader, sample fallback)

Migration approach: keep the wrappers small (each ~50 lines) so the wizard's
familiar API stays at call sites — `<Button variant="primary" icon="sparkles">`
still works and now goes through Radix Button under the hood.

Each consumer's import line was rewritten via a Python regex pass that
splits the bundled `import { X, Y, Z } from '../primitives.jsx'` into
N separate per-component imports. studio/primitives.jsx and primitives.d.ts
deleted.

23 files migrated. TypeScript clean. Visual verified on /step/1, /step/3,
/render — segmented icons (sparkles, image, mic, copy, upload) render
correctly via Icon string lookup in the wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): Phase D part 2 — strip dead CSS + migrate remaining .btn/.card classes

After primitives.jsx was deleted, these CSS rules became dead weight in
app.css. Removed 43 rule blocks (5021 bytes):
  .btn, .btn-{primary,secondary,ghost,danger,sm,lg,icon}
  .btn:disabled + 4 hover variants
  .card, .card-{header,title,subtitle,eyebrow}
  .modal-backdrop, .modal, .modal-{header,body}
  .badge, .badge-{neutral,accent,success,warn}
  .seg, .seg button, .seg button.on
  .slider-row, .slider-track, .slider-fill, .slider-thumb (+ hover), .slider-value
  .tabs, .tab (+ hover, .on)
  .chip, .chip:hover, .chip.on

Kept (still used in source):
  .topbar/.brand/.brand-mark/.step-pill/.stepper (TopBar)
  .left-col/.right-col/.main/.app-shell (WizardLayout)
  .step-page/.step-heading/.step-footer/.validation-msg
  .input/.textarea/.input-group + suffix/prefix (raw form inputs)
  .field-row/.field-row-3/.field-label (still in HostTextForm/VoiceAdvancedSettings)
  .upload-tile/.has-file/.file-thumb/.file-meta/.file-buttons (UploadTile-internal)
  .res-grid/.res-tile/.res-label/.res-dim/.res-meta (ResolutionPicker)
  .preset-grid/.preset-tile (BackgroundPicker)
  .voice-item/.voice-avatar/.voice-info/.voice-name/.voice-meta/.voice-play
  .product-ref-chip, .skeleton-shimmer, .hr, .num/.mono/.truncate, .tweaks-header

Six remaining `.btn` / `.card` class usages migrated to Tailwind utilities:
  VoicePicker play button (h-8 size-8 rounded-md hover:bg-secondary)
  ProductList upload label + delete button
  BackgroundPicker "서버에서 선택" button
  ScriptEditor 문단 추가 + 삭제 buttons
  ServerFilePicker entire modal (replaced with shadcn Dialog)
  ResultActions/RenderActions download anchor (Tailwind utility)
  RenderDashboard/RenderHistory/ResultPage/ProvenanceCard/RenderStats/
  ResultStats: .card→surface-base, .card-eyebrow→Tailwind utility chain

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): hover color on neutral buttons should be muted gray, not primary blue

User caught: shadcn ships hover:bg-accent on outline/ghost/secondary
button variants + on dropdown/select/menu item focus states. Our design
system maps --accent to #C0D8FF (light blue) so every neutral button —
"이전" / "다시 만들기" / "추천 장소에서 고르기" toggle / sidebar rows /
profile menu items — flashed primary blue on hover. Looked branded in a
bad way: tonal mismatch (gray button → blue hover).

Fix: replace hover:bg-accent with hover:bg-muted (gray #F5F5F5) on every
neutral surface. Active/selected states (toggled-on Segmented item, primary
CTA, brand badge) keep the blue tint — those are real "active" signals
where primary tone is correct.

Files touched:
  components/ui/button.tsx        outline + ghost hovers
  components/ui/toggle.tsx        outline variant hover (drives Segmented)
  components/ui/dropdown-menu.tsx 5 item types' focus states
  components/ui/select.tsx        SelectItem focus
  components/ui/badge.tsx         outline + ghost link hovers
  components/ui/dialog.tsx        close X button data-state-open
  routes/ResultsListPage.tsx      sidebar row + [⋯] trigger hover
  routes/HomePage.tsx             ActionButton secondary hover

Verified by hovering "사진으로 만들기" segmented in /step/1 — now light
gray, not light blue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): step-tailored wizard layouts + design system extraction

Codex audit drove a per-step layout pivot — the universal right-rail
"summary panel" was generic SaaS dashboard slop. Each step now has its
own architecture matching what the user is actually doing there:

  Step 1 (쇼호스트 만들기) — 40/60 split, audition gallery dominant
    Form left as casting brief, 4-thumb candidate grid right
    (Midjourney generate-then-pick pattern).
  Step 2 (제품·배경 합성) — 50/50 split, evolving canvas
    Form left, composite preview + variants picker right.
  Step 3 (음성·영상) — 65/35 split, render booth
    Wide form left (voice list + script + resolution),
    persistent 9:16 preview + commit CTA right (Sora final-step).

Wizard primitive unification: every step has one dominant artifact +
contextual controls + explicit commit action. Wizard layout collapses
its old 2-col grid into a single content area (.wizard-stage); each
step page owns its internal split.

Component extraction (was inline-duplicated 3x each):
  components/option-card.tsx — shared mode picker (icon + title +
    desc + meta), with built-in hover-locks-active state to dodge
    Tailwind v4's hover variant out-cascading data-state= rules.
  components/wizard-tabs.tsx — shadcn Tabs wrapper with the
    enclosed-track styling that lived as 5 duplicated 200-char
    className strings.
  Sidebar / AppLayout — primary-nav productivity shell with
    workspace identity + new-video CTA, ditching the old AppHeader.

Queue popover migrated from hand-rolled portal/anchor/outside-click
to shadcn Popover (Radix). The hand-rolled version got clipped by
topbar's overflow-x:auto.

Background preset tiles drop the arbitrary gradient swatch (didn't
represent any real result) for icon-based tiles.

bg-accent-soft / accent-text registered in @theme so the related
utility classes actually emit styles.

sonner added for toast plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: delete verified-dead spike + preview files (refactor Phase 0)

  studio/PreviewPanel.jsx        — 0 importers (replaced by per-step
                                   preview docks in the layout refactor)
  studio/PhaseMinusOneSpike.jsx  — only spike.test.jsx imports it
  studio/__tests__/spike.test.jsx

git grep confirms no remaining references. Frontend refactor plan
Phase 0; see docs/frontend-refactor-plan.md (added in next commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(wizard): introduce typed schema foundation (refactor Phase 1)

Codex audit identified the dominant frontend smell as "no real wizard
domain model" — the store declared every slice as
Record<string, unknown>, validation ran on `any`, and legacy fields
(_gradient, _file, imageUrl, selectedPath, ...) drifted between UI,
persistence, and API mappers with nobody owning their lifecycle.
Result: the same class of bug kept surfacing (bg-accent-soft producing
no styles for months because nothing typed-checked the className,
44 surviving _gradient references after their consumers were deleted,
22 separate `state: any` re-assertions across step components).

Phase 1 is pure additive. New canonical typed model lives alongside
the legacy store; Phase 2a/b/c migrate one slice at a time.

  src/wizard/schema.ts         — tagged unions for all 6 slices.
                                 host (input + generation lifecycle),
                                 background (preset/upload/url/prompt),
                                 products (empty/localFile/uploaded/url),
                                 composition, voice (tts/clone/upload),
                                 resolution. Plus readiness predicates.
  src/wizard/normalizers.ts    — migrateLegacy() reads pre-schema
                                 persisted blobs into typed state.
                                 toPersistable() / persistBackground()
                                 strip File handles + blob: URLs.
  src/wizard/api-mappers.ts    — schema → backend payload (the only
                                 place that constructs API request
                                 bodies). Covers host, composite,
                                 voice, render.
  src/wizard/__tests__/normalizers.test.ts — 17 unit tests covering
                                 every legacy → schema migration path
                                 + persist round-trip + readiness.

  docs/frontend-refactor-plan.md — full plan, 7 phases, sequencing,
                                 risk notes, done criteria.

No runtime change in this commit — store still consumes the legacy
shape. Phase 2a switches the background slice over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(wizard): migrate background slice to tagged-union schema (Phase 2a)

Background was the legacy shape's worst offender — a single object
held source enum + preset field + url field + prompt field + imageUrl
+ _gradient + _file + uploadPath + serverFilename, with no constraint
that exactly one set was filled. Tagged union (kind: preset | upload
| url | prompt) makes the impossible combinations unrepresentable.

Store
  wizardStore.background type → schema Background (was WizardSlice).
  setBackground replace-style (Background | (prev) => Background).
    Partial<TaggedUnion> doesn't compose, so callers hand the whole
    next slice or a deriver function.
  partializeForPersist now uses persistBackground from
    wizard/normalizers — drops LocalAsset (File + blob URL), keeps
    ServerAsset.
  persist version bumped 1 → 2 with migrate() running migrateLegacy
    so existing users with v1 persisted state auto-upgrade on next
    load (legacy shape shapes auto-translate; no data loss).

UI
  BackgroundPicker rewritten — emits a full Background, no more
    Partial<Background> patches. Tier-2 sub-tab clicks construct a
    fresh slice ({kind: 'preset', presetId: null}, etc).
  Step2Composite — bgReady = isBackgroundReady (schema predicate).
    Generate flow uses isLocalAsset/isServerAsset to detect
    pending-upload, swap to ServerAsset on success, then map to the
    legacy composite-API shape via inline backgroundToLegacyApi (will
    consolidate into wizard/api-mappers when other slices migrate).
  picker_handler.applyPickedFileToBackground deleted — schema
    construction is now a 1-line literal, no merge reducer needed.

API
  api/video.ts wizardStateForFingerprint → backgroundProvenance
    helper bridges schema Background to the legacy provenance JSON
    blob the backend's _synthesize_result consumes. Wire format
    unchanged.
  GenerateVideoInput.background type tightened to schema Background.

Tests
  __tests__/api.test.js generateVideo provenance test updated for the
    new Background shape (presetLabel is now derived UI-side from
    BG_PRESETS table, not carried in state — provenance returns null).
  __tests__/step2_picker_handler.test.js drops the
    applyPickedFileToBackground suite (function deleted).
  wizard/normalizers.test.ts (17 cases) covers the legacy →
    schema migration that now runs on every existing user's hydrate.

Verified end-to-end in the browser: planted a v1 legacy
{source:'preset', preset:'living_cozy', _gradient, _file, ...}
state, navigated to /step/2, persisted blob auto-rewrote to schema
{kind:'preset', presetId:'living_cozy'}, UI rendered the correct
selected tile.

Phase 2b (host slice) next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(wizard): migrate host slice to schema state machine (Phase 2b)

Host state was the second-worst legacy offender — input fields for
two completely different modes (text + image) lived flat alongside
selection markers (selectedSeed/selectedImageId/selectedPath/imageUrl
/_gradient) and stream artefacts (variants/prevSelected/batchId), with
no constraint that text-mode inputs were nullable in image mode or
vice-versa, and no representation of the streaming lifecycle. The
schema split this into:

  Host = { input: HostInput; temperature; generation: HostGeneration }
  HostInput = {kind:'text', prompt, builder, ...}
            | {kind:'image', faceRef, outfitRef, faceStrength, ...}
  HostGeneration = {state:'idle'}
                 | {state:'streaming', batchId, variants}
                 | {state:'ready', batchId, variants, selected, prevSelected}
                 | {state:'failed', error}

Tagged unions make the impossible combinations unrepresentable
(prompt in image mode, selected without variants, streaming with
selected committed).

Store
  wizardStore.host type → schema Host (was WizardSlice).
  setHost replace-style (Host | (prev) => Host).
  partializeForPersist host → persistHost (state-machine collapses
    streaming/failed → idle on reload).
  persist version 2 → 3 with migrate() running migrateLegacy on the
    host slice for users with v2 persisted state.
  migrateLegacyStateOnce envelope tagged version: 3 (was 1) so
    Zustand's own migrator skips redundant re-migration.

Hook
  useHostGeneration rewritten — local UI variant type with placeholder/
    error/isPrev for mid-stream rendering, schema HostVariant for the
    persisted shape. Drives generation state machine via setHost
    (streaming → ready on `done`, → failed on fatal). Initial state
    seeds from store's `host.generation.variants` when state ready.

UI
  Step1Host rewritten — reads host.input.kind discriminator, switches
    between HostTextForm and HostReferenceUploader. setInput helper
    uses replace-style narrowing (`prev.kind === 'text' ? ... : prev`)
    so cross-mode field accesses are compile-errors. Selection writes
    `generation.selected` instead of legacy `imageUrl/selectedPath/...`
    sprawl. Drops `_gradient` carry — no longer in schema.
  HostReferenceUploader still on the legacy RefFile prop API; bridged
    via `assetToRefFile` helper — converts ServerAsset/LocalAsset to
    the legacy {name, url, size, type} shape. Sub-component migrates
    in a later pass.

Validation
  computeValidity v[1] → state.host.generation.state === 'ready' AND
    selected !== null. Was checking legacy `host.generated` /
    `host.imageUrl`.

Provenance (api/video.ts)
  hostProvenance helper bridges schema Host → the legacy provenance
    blob {mode, selectedSeed, selectedPath, imageUrl, prompt,
    faceRefPath, ...} that backend _synthesize_result + ProvenanceCard
    consume. Wire format unchanged.

Normalizers
  migrateHostVariants — derives imageId from path when legacy variants
    lacked the explicit imageId field (server identifier scheme is
    "filename stem without extension"; same as api/mapping
    imageIdFromPath, duplicated to keep wizard/* free of api/* deps).
  migrateHost — selectedSeed / selectedImageId / selectedPath any of
    them now binds the legacy commitment signal to a schema-shaped
    `selected` variant (was: only `generated: true` triggered the
    binding, missing the path-only case).

Tests
  api.test.js generateVideo — host fixture rewritten as schema-shaped
    {input, temperature, generation} (was legacy flat fields).
  legacy_migration.test.js envelope assertions check schema shape
    (input.kind, generation.state/selected/variants) and version: 3.
  wizard/normalizers.test.ts (17 cases, unchanged) — still all green.

Verified end-to-end in browser: planted v2 envelope with legacy host
{mode, generated, selectedSeed, variants}, navigated to /step/1,
persisted blob auto-rewrote to schema {input, generation.{state:
'ready', selected: ...}}, audition gallery renders variants with the
correct selection.

Phase 2c (products + composition + voice + resolution) next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(wizard): migrate resolution slice to schema key (Phase 2c.1)

Resolution was a 7-field object {key, label, width, height, size,
speed, default} where 6 fields were derivable from `key`. Schema
makes the key the source of truth; consumers look up meta via
`RESOLUTION_META[key]`.

Store
  resolution type → ResolutionKey ('448p' | '480p' | '720p' | '1080p').
  setResolution(key) replaces setResolution(presetObject).
  persist version 3 → 4 with migrate() (legacy {key,...} → 'key' string).
  partializeForPersist no special handling — string is already inert.

UI
  ResolutionPicker emits the key only. RES_OPTIONS table reduced to
    just the per-tile time/warn flags (the meta lives in schema's
    RESOLUTION_META).
  Step3Audio reads `state.resolution as ResolutionKey`, looks up
    width/height/label via RESOLUTION_META[key] for the render booth
    "고화질(HD) · 720p · 720×1280" line.
  RenderDashboard same pattern for the in-flight progress dimensions.

Validation
  v[3] check changed from `!!state.resolution.key` to
  `typeof state.resolution === 'string'` — schema invariant means
  presence of the key implies validity.

API
  api/video.ts:
    body.append('resolution', stringifyResolution(RESOLUTION_META[key]))
    queue label uses RESOLUTION_META[key].label.
    GenerateVideoInput.resolution narrowed to ResolutionKey | null.

Tests
  api.test.js generateVideo state.resolution → '720p' string.
  legacy_migration version assertion bumped 3 → 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(wizard): migrate products slice to schema source union (Phase 2c.2)

Products were a flat 7-field shape {id, source: 'upload'|'url',
url, urlInput, name, path, _file} where 5 of those fields were
mutually-exclusive depending on `source`. Schema:

  Product = { id, name, source: ProductSource }
  ProductSource = {kind:'empty'}
                | {kind:'localFile', asset: LocalAsset}
                | {kind:'uploaded', asset: ServerAsset}
                | {kind:'url', url, urlInput}

Tagged union → cross-mode field reads are compile-errors
(`p.url` invalid; consumers must narrow via `p.source.kind === ...`).

Store
  products: Product[] (was WizardSlice[]).
  setProducts retains updater signature; types tighter.
  partializeForPersist drops localFile (transient File handle + blob/
    data: URL) by collapsing to `{kind:'empty'}` so a reload shows a
    fresh empty slot to re-upload.
  persist version 4 → 5 with migrate() running migrateProducts.

UI
  ProductList rewritten — `previewUrl(p)` helper derives the display
    URL from the source discriminator. Source-mode toggle constructs
    a fresh tagged value ({kind:'empty'} or {kind:'url', url:'',
    urlInput:''}) instead of patching loose fields. File-input handler
    builds a localFile asset.
  Step2Composite — productsReady checks `source.kind !== 'empty'`.
    Generate flow uploads localFile rows in parallel via
    productUpload.upload, swapping each to {kind:'uploaded', asset:
    ServerAsset}. Backend payload mapping pulls path from the
    uploaded variant only.
  CompositionControls product chip thumb derives URL from the
    source.kind discriminator.

picker_handler.applyPickedFileToProducts emits a Product with
  source.kind === 'uploaded'. Replace-rule now matches rows whose
  source is 'empty' or 'localFile' (both signal "this slot wants a
  server path"); 'uploaded' / 'url' rows are kept and the new pick
  appends.

Tests
  step2_picker_handler.test.js rewritten for schema fixtures (4
  cases). legacy_migration version assertion 4 → 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(wizard): migrate composition slice to settings + state machine (Phase 2c.3)

Mirror of host (Phase 2b). Composition was a flat shape with
direction/shot/angle/temperature/rembg as user-tweaked settings, and
generated/selectedSeed/Path/Url/ImageId/variants/prevSelected/batchId
as stream artefacts — no separation of concerns. Schema:

  Composition = { settings: CompositionSettings; generation: CompositionGeneration }
  CompositionSettings = { direction; shot; angle; temperature; rembg }
  CompositionGeneration = {state:'idle'}
                        | {state:'streaming', batchId, variants}
                        | {state:'ready', batchId, variants, selected, prevSelected}
                        | {state:'failed', error}

Settings + generation are independent slices — UI controls touch
settings, the streaming hook manages generation, and they don't
trample each other (the legacy "patch the whole composition object"
pattern routinely cleared variants when the user typed in the
direction textarea).

Store
  composition: Composition (was WizardSlice).
  setComposition replace-style.
  partializeForPersist: persistComposition collapses streaming/failed
    generation states to idle on reload.
  persist version 5 → 6 with migrate(); legacy migrator routes
    composition through migrateLegacyToSchema.

Hook
  useCompositeGeneration rewritten — local UI variant type for
    placeholder/error rendering, schema CompositionVariant for the
    persisted shape. Drives generation state machine via setComposition
    (streaming → ready on done, → failed on fatal). Initial state
    seeds from store's `composition.generation.variants` when ready.
    direction_en debug echo dropped — not modeled in schema.

UI
  Step2Composite reads composition.settings (direction/shot/angle/
    temperature/rembg) for the form, composition.generation for the
    canvas. setSettings is the only UI mutation point for settings;
    generation moves are owned by the hook + selectComposite.
  CompositionControls — accepts `settings: CompositionSettings` +
    `onSettingsChange(patch)` instead of the old `composition` /
    `onCompositionChange`. All `composition.direction` etc. references
    in the body became `settings.direction`.
  CompositeCanvas reads `generation.selected` for the hero image
    and the "선택한 결과" / "다음 단계로" footer.

Validation
  v[2] checks generation.state === 'ready' && selected != null
    (replaces legacy `composition.generated`).

Provenance
  api/video.ts compositionProvenance helper bridges schema to the
    legacy {selectedSeed, selectedPath, selectedUrl, direction, shot,
    angle, temperature} blob backend manifest expects. Wire format
    unchanged.
  GenerateVideoInput.composition narrowed to Composition | null.

Tests
  api.test.js generateVideo composition fixture rewritten as
    {settings, generation: {state: 'ready', selected: ...}}.
  legacy_migration version assertion 5 → 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(wizard): migrate voice slice to schema tagged union (Phase 2c.4)

Final slice of the Phase 2 schema refactor. Voice now lives in the store
as a tagged-union `Voice = TTS | Clone | Upload`, with `VoiceGeneration`
and `VoiceCloneSample` state machines. Hooks drive the transitions; UI
consumes typed slices.

Wiring:
- wizardStore.voice typed Voice (was WizardSlice = Record<string, unknown>)
- setVoice replace-style (Voice | (prev) => Voice) — tagged unions don't
  compose with Partial<>
- persist version 6→7 via migrateLegacyToSchema({voice}); persistVoice
  drops mid-stream generation states, pending clone samples, and
  LocalAsset audio uploads
- legacy migrator on the pre-Phase-2b localStorage key writes envelopes
  tagged with version 7

Hooks:
- useTTSGeneration: idle → generating → ready/failed on
  voice.generation; uses toVoiceGenerateRequest from api-mappers
- useVoiceClone: empty → pending → cloned on voice.sample
  (clone source only)

UI (Step3Audio + sub-components):
- Step3Audio orchestrates source-mode transitions (toTTS / toClone /
  toUpload preserve script + advanced where union members agree)
- VoicePicker, VoiceCloner, AudioUploader, ScriptEditor,
  VoiceAdvancedSettings retyped to schema slices
- Eager-upload wired in upload-mode AudioUploader: previously broken
  (uploadAudio() in api/upload.ts had zero callers, voice.uploadedAudio.path
  was never set, render dispatch crashed). Now Step3Audio watches
  voice.audio for LocalAsset, kicks off uploadAudio, swaps to ServerAsset

API:
- voiceProvenance helper in api/video.ts mirrors the existing
  host/composition/background helpers — schema → legacy provenance keys
  the backend manifest + ProvenanceCard expect
- GenerateVideoInput.voice typed Voice | null
- RenderDashboard audio_path resolution updated to schema shape
  (voice.generation.audio.path for tts/clone, voice.audio.path for upload)
- wizardValidation v[3] uses isVoiceReady from schema

Tests:
- normalizers.test.ts +5 persistVoice cases (22 tests, all green)
- legacy_migration.test.js version assertion 6→7 + voice narrowing
- api.test.js voice fixture upgraded to schema shape
- state_persist.test.js voice block upgraded to voice.audio shape

Phase 2 of the wizard refactor is now complete — all 5 slices
schema-typed (background → host → resolution → products → composition →
voice). Phase 3 (per-slice selectors, kill updateState) is next per
docs/frontend-refactor-plan.md.

Browser-verified on 2026-04-26: 3 voice modes mount cleanly,
mode-switching works, refresh persists upload-mode subtitle text,
v6 envelope migrates to v7 carrying legacy voiceId/voiceName/script/
advanced through to the schema shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(wizard): collapse duplicate shot picker buttons; use industry-standard labels

Phase 2c.3 collapsed the schema's CompositionShot to close|medium|far,
but the UI kept 4 buttons with both 상반신 and 미디엄 wired to 'medium'
— two buttons, identical behavior, confusing UX.

Step 2 shot picker now matches the schema with Korean film-industry
standard labels:
  클로즈업 (Close-Up)   — 얼굴 중심
  미디엄샷 (Medium Shot) — 머리~허리
  풀샷    (Full Shot)   — 전신

Tooltips on every chip describe the framing range so users don't have
to know the cinematography terms. Same treatment applied to the angle
picker (정면/살짝 아래에서/살짝 위에서) — labels stay user-friendly,
tooltips add the technical names (아이레벨/로우앵글/하이앵글).

ProvenanceCard SHOT_LABELS keeps all 4 historical values distinct so
old manifests stay readable: bust → 바스트샷 (legacy 머리~가슴),
medium → 미디엄샷 (머리~허리), full/far → 풀샷, closeup/close → 클로즈업.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: fix state_persist tests + add motor/pymongo/bcrypt to python install

Two CI-only failures left over from the wizard schema refactor and the
playlist feature landing without CI dep updates.

state_persist.test.js: 4 cases asserted pre-Phase-2a/2b/2c.2 shapes
(flat host.variants, flat host.faceRef, flat products with _file/path).
Each phase shipped without bringing this test in sync. Rewrites:

- "preserves finished host variants" — assertions move to
  host.generation.{state, variants, selected} per the schema
- "strips placeholder variants" replaced with "collapses
  streaming/failed host generation to idle on persist" — the new
  invariant since streaming UI variants are local-only
- "preserves face/outfit ref" — image-mode input shape:
  host.input.faceRef as ServerAsset
- "drops face/outfit ref" — LocalAsset (file + previewUrl) → null
- "strips product _file" — ProductSource tagged-union; localFile →
  empty, uploaded ServerAsset survives
- "preserves finished composition variants" — composition.generation
  shape

All 8 tests in the file now green locally.

CI workflow python install: studio_*_repo tests (playlist, result,
saved_host, user) and auth tests import motor.AsyncIOMotorClient,
pymongo, bcrypt, jwt at module load. CI was missing all four — pytest
errored during collection with ModuleNotFoundError before any test
ran. Added them to the install line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: declare @ alias in vitest.config + fix humanizeError test + skip stale step2 integration tests

The frontend CI was failing 6 test files at collection because vitest
reads its own config and doesn't inherit vite.config.js — the @
path alias was only declared in vite.config.js, not vitest.config.j…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants