fix for windows. - #13
Open
xuhongming251 wants to merge 1 commit into
Open
Conversation
add time log.
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…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix for windows.
add time log.