diff --git a/docs/development/INTERNATIONALIZATION.md b/docs/development/INTERNATIONALIZATION.md index b3b3209ba..504c54443 100644 --- a/docs/development/INTERNATIONALIZATION.md +++ b/docs/development/INTERNATIONALIZATION.md @@ -24,6 +24,11 @@ Typed JSON under `ui/src/i18n/locales//.json`. | `styleSheet` | Style sheet library, import and delete chrome | | `projects` | Durable projects catalog | | `auditDev` | Internal audio-hallucination audit panel | +| `scene3d` | 3D Video, Hunyuan3D, Rig & Animate | +| `shell` | Intro, LAN gate, preflight/OOM/download banners | +| `characters` | Character Creator and Character Kits | +| `comics` | Comic Studio | +| `studio` | Direct generation sidebar (prompt, inputs, modes, tools) | Add a namespace only when a new product surface needs its own file. Do not grow a single giant JSON. diff --git a/tests/test_h3_window_planner.py b/tests/test_h3_window_planner.py index d6f68d3f4..b572d9c8a 100644 --- a/tests/test_h3_window_planner.py +++ b/tests/test_h3_window_planner.py @@ -388,14 +388,17 @@ def test_ui_and_runtime_use_explicit_prompt_arrays(self): advanced = (ROOT / "ui" / "src" / "components" / "Sidebar" / "AdvancedSettings.tsx").read_text(encoding="utf-8") prompt_input = (ROOT / "ui" / "src" / "components" / "Sidebar" / "PromptInput.tsx").read_text(encoding="utf-8") main_content = (ROOT / "ui" / "src" / "components" / "MainContent" / "MainContent.tsx").read_text(encoding="utf-8") + studio = json.loads((ROOT / "ui" / "src" / "i18n" / "locales" / "en" / "studio.json").read_text(encoding="utf-8")) guide = APP / "services" / "llm_guides" / "enhance" / "minimax_h3_sliding_windows.md" self.assertIn("h3_window_prompts=None", handler) self.assertIn("Using {len(prompts)} explicit", handler) self.assertIn('/api/v1/llm/plan-h3-windows', llm_router) self.assertIn("h3_window_plan_signature", launch) self.assertIn("api.planH3Windows", store) - self.assertIn("Plan Prompt Across Windows", advanced) - self.assertIn("Exact H3 prompts", prompt_input) + self.assertIn("t('advanced.planWindows')", advanced) + self.assertEqual(studio["advanced"]["planWindows"], "Plan Prompt Across Windows") + self.assertIn("t('prompt.h3Exact'", prompt_input) + self.assertIn("Exact H3 prompts", studio["prompt"]["h3Exact"]) self.assertIn("H3WindowPromptTextarea", prompt_input) self.assertIn("textarea.scrollHeight", prompt_input) self.assertNotIn("max-h-[360px] overflow-y-auto", prompt_input) diff --git a/tests/test_minimax_h3.py b/tests/test_minimax_h3.py index f92dbb495..71e14598f 100644 --- a/tests/test_minimax_h3.py +++ b/tests/test_minimax_h3.py @@ -54,6 +54,7 @@ _ASPECT_RATIO_GRID_PATH = _ROOT / "ui" / "src" / "components" / "Sidebar" / "AspectRatioGrid.tsx" _MODEL_SELECTOR_PATH = _ROOT / "ui" / "src" / "components" / "Sidebar" / "ModelSelector.tsx" _LORA_SELECTOR_PATH = _ROOT / "ui" / "src" / "components" / "SettingsDrawer" / "LoraSelector.tsx" +_STUDIO_EN_PATH = _ROOT / "ui" / "src" / "i18n" / "locales" / "en" / "studio.json" _ENHANCE_GUIDES_PATH = _APP / "services" / "enhance_guides.py" _PROMPT_POLISH_PATH = _APP / "services" / "director" / "prompt_polish.py" _H3_ENHANCE_GUIDE_PATH = _APP / "services" / "llm_guides" / "enhance" / "minimax_h3_video.md" @@ -799,6 +800,7 @@ def test_ref2va_default_and_handler_contract_are_separate_from_fl2va(self): self.assertIn("audio references", model_def["selector_help"]) def test_h3_selector_names_and_audio_badges_are_user_facing(self): + studio = json.loads(_STUDIO_EN_PATH.read_text(encoding="utf-8")) expected_names = { _DEFAULT_PATH: "H3 First / Last — Pruned", _LEGACY_DEFAULT_PATH: "H3 Legacy Quality — ConvRot", @@ -820,8 +822,10 @@ def test_h3_selector_names_and_audio_badges_are_user_facing(self): self.assertIn("converts Pruned adapters", full_omni["lora_compatibility_note"]) selector = _read(_MODEL_SELECTOR_PATH) - self.assertIn("Audio Out", selector) - self.assertIn("Audio In", selector) + self.assertIn("t('model.audioOut')", selector) + self.assertIn("t('model.audioIn')", selector) + self.assertEqual(studio["model"]["audioOut"], "Audio Out") + self.assertEqual(studio["model"]["audioIn"], "Audio In") self.assertNotIn("badges.push('Audio')", selector) self.assertIn("lora_compatibility_note", _read(_LORA_SELECTOR_PATH)) launch = _read(_LAUNCH_PATH) @@ -877,6 +881,7 @@ def test_studio_h3_duration_and_window_controls_are_model_aware(self): advanced = _read(_ADVANCED_SETTINGS_PATH) store = _read(_STORE_PATH) launch = _read(_LAUNCH_PATH) + studio = json.loads(_STUDIO_EN_PATH.read_text(encoding="utf-8")) self.assertIn( "supportsSlidingWindows = modelOptions?.sliding_window === true", @@ -894,7 +899,8 @@ def test_studio_h3_duration_and_window_controls_are_model_aware(self): self.assertIn("unsupportedAutoResolution", duration) self.assertIn("fallbackResolution", duration) self.assertIn("sliding_window_memory_override", store) - self.assertIn("full prompt auto-paced", duration) + self.assertIn("t('duration.autoPaced')", duration) + self.assertEqual(studio["duration"]["autoPaced"], "full prompt auto-paced") self.assertIn('"sliding_window_memory_policy": md.get(', launch) self.assertIn('h3_window_adjustment.get("unsupported")', launch) @@ -989,6 +995,7 @@ def test_omni_reference_request_and_ui_are_wired_end_to_end(self): llm_slice = _read(_LLM_SLICE_PATH) section = _read(_OMNI_REFERENCE_SECTION_PATH) generate_button = _read(_GENERATE_BUTTON_PATH) + studio = json.loads(_STUDIO_EN_PATH.read_text(encoding="utf-8")) self.assertIn('if _generation_model_def.get("omni_reference"):', launch) self.assertIn("validate_reference_manifest", launch) self.assertIn("per_clip_minimax_h3_references", launch) @@ -1004,13 +1011,19 @@ def test_omni_reference_request_and_ui_are_wired_end_to_end(self): self.assertIn("intent=AUDIO REUSE / PERFORMANCE DRIVER", llm_slice) self.assertIn("intent=VOICE REFERENCE", llm_slice) self.assertIn('draggable', section) - self.assertIn("Include soundtrack", section) - self.assertIn("Attach audio", section) + self.assertIn("t('omni.includeSoundtrack')", section) + self.assertIn("t('omni.attachAudio')", section) self.assertIn("audio_path", section) - self.assertIn("Maximum detail", section) - self.assertIn("Voice reference", section) - self.assertIn("Drive / reuse audio", section) - self.assertIn("Sound / music style", section) + self.assertIn("t('omni.max')", section) + self.assertIn("t('omni.voiceRef')", section) + self.assertIn("t('omni.drive')", section) + self.assertIn("t('omni.style')", section) + self.assertEqual(studio["omni"]["includeSoundtrack"], "Include soundtrack") + self.assertEqual(studio["omni"]["attachAudio"], "Attach audio") + self.assertEqual(studio["omni"]["max"], "Maximum detail") + self.assertEqual(studio["omni"]["voiceRef"], "Voice reference") + self.assertEqual(studio["omni"]["drive"], "Drive / reuse audio") + self.assertEqual(studio["omni"]["style"], "Sound / music style") self.assertIn("const hasOmniVisualReference = useStore(s =>", generate_button) self.assertNotIn( "useStore(s => s.params.minimax_h3_references ?? [])", @@ -1315,6 +1328,7 @@ def test_managed_turbo_choice_is_discoverable_for_full_and_pruned(self): sidebar = _read(_SIDEBAR_PATH) advanced = _read(_ADVANCED_SETTINGS_PATH) types_source = _read(_TYPES_PATH) + studio = json.loads(_STUDIO_EN_PATH.read_text(encoding="utf-8")) self.assertIn("def _minimax_h3_turbo_option", launch) self.assertIn('names.add(turbo_option["filename"])', launch) @@ -1324,11 +1338,13 @@ def test_managed_turbo_choice_is_discoverable_for_full_and_pruned(self): self.assertIn("_minimax_h3_runtime_advisory", launch) self.assertIn("normalize_minimax_h3_turbo_request", launch) self.assertIn("", sidebar) - self.assertIn("Experimental", toggle) + self.assertIn("t('chrome.experimental')", toggle) self.assertIn("setParam('num_inference_steps', option.steps)", toggle) self.assertIn("toggleLora(option.filename)", toggle) self.assertIn("setLoraWeight(option.filename, 0, option.weight)", toggle) - self.assertIn("Use Pruned Turbo", toggle) + self.assertIn("t('h3Turbo.usePruned')", toggle) + self.assertEqual(studio["chrome"]["experimental"], "Experimental") + self.assertEqual(studio["h3Turbo"]["usePruned"], "Use Pruned Turbo") self.assertIn("recommended_model_type", toggle) self.assertIn("disabled={h3TurboMode}", advanced) self.assertIn("minimax_h3_turbo_mode?: boolean", types_source) diff --git a/tests/test_phase1_issue_fixes.py b/tests/test_phase1_issue_fixes.py index 5af35f013..27f1199af 100644 --- a/tests/test_phase1_issue_fixes.py +++ b/tests/test_phase1_issue_fixes.py @@ -3,6 +3,7 @@ import ast import importlib.util +import json import math import os import sys @@ -68,6 +69,9 @@ "Sidebar", "OutpaintCanvas.tsx", ) +_STUDIO_EN_PATH = os.path.join( + _ROOT, "ui", "src", "i18n", "locales", "en", "studio.json", +) _requires_torch = unittest.skipUnless( importlib.util.find_spec("torch") is not None, @@ -1725,7 +1729,9 @@ def test_ui_replaces_misleading_controls_with_one_recommended_toggle(self): controls = _read(_OUTPAINT_CONTROLS_PATH) client = api_client_source() store = _read(_STORE_PATH) - self.assertIn("Preserve original scene", controls) + studio = json.loads(_read(_STUDIO_EN_PATH)) + self.assertIn("t('outpaint.preserveScene')", controls) + self.assertEqual(studio["outpaint"]["preserveScene"], "Preserve original scene") self.assertIn("outpaintMaskPreserving: true", store) self.assertIn("mask_preserving_outpaint?: boolean", client) self.assertIn("outpaint_aspect?:", client) @@ -1760,9 +1766,12 @@ def test_outpaint_generate_button_explains_zero_generation_area(self): "GenerateButton.tsx", ) ) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn("needsOutpaintArea", generate_button) - self.assertIn("Choose canvas", generate_button) - self.assertIn("area for Outpaint to generate", generate_button) + self.assertIn("t('generate.chooseCanvas')", generate_button) + self.assertIn("t('generate.outpaintAreaHint')", generate_button) + self.assertEqual(studio["generate"]["chooseCanvas"], "Choose canvas") + self.assertIn("area for Outpaint to generate", studio["generate"]["outpaintAreaHint"]) def test_backend_uses_official_lora_and_internal_blend(self): launch = _read(_LAUNCH_PATH) @@ -2692,14 +2701,18 @@ def test_server_and_frontend_use_durable_visibility(self): class TestFramesControlVideoAudio(unittest.TestCase): def test_control_video_presence_is_not_derived_from_audio_mode(self): source = _read(_INPUTS_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn( "const hasControlVid = supportsControlVid && " "!!params.video_guide", source, ) - self.assertIn("Generate soundtrack from text prompt", source) - self.assertIn("Generate new audio from control video", source) - self.assertIn("The control video remains attached", source) + self.assertIn("t('inputs.generateFromText')", source) + self.assertIn("t('inputs.generateFromControl')", source) + self.assertIn("t('inputs.controlStays')", source) + self.assertEqual(studio["inputs"]["generateFromText"], "Generate soundtrack from text prompt") + self.assertEqual(studio["inputs"]["generateFromControl"], "Generate new audio from control video") + self.assertIn("The control video remains attached", studio["inputs"]["controlStays"]) self.assertIn("rawControlProcess", source) self.assertNotIn("supportsSoundtrack && !hasControlVid", source) self.assertNotIn("supportsControlVid && !hasSoundtrack", source) diff --git a/tests/test_scail2_workflows.py b/tests/test_scail2_workflows.py index 77853c6cc..a8ea391c4 100644 --- a/tests/test_scail2_workflows.py +++ b/tests/test_scail2_workflows.py @@ -78,6 +78,9 @@ _LORA_SELECTOR_PATH = os.path.join( _ROOT, "ui", "src", "components", "SettingsDrawer", "LoraSelector.tsx", ) +_STUDIO_EN_PATH = os.path.join( + _ROOT, "ui", "src", "i18n", "locales", "en", "studio.json", +) def _read(path: str) -> str: @@ -3628,6 +3631,7 @@ def test_recast_offers_single_reference_native_group_conditioning(self): wan_handler = _read(_WAN_HANDLER_PATH) store = _read(_STORE_PATH) controls = _read(_RECAST_CONTROLS_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn('if "preserve_bystanders" in body:', launch) self.assertIn('body.get("preserve_bystanders") is True', launch) self.assertIn('body.get("preserve_scene_reference") is True', launch) @@ -3686,7 +3690,11 @@ def test_recast_offers_single_reference_native_group_conditioning(self): self.assertIn("if map_native_bystanders:", launch) self.assertIn("skipping the second full SAM3 pass", launch) self.assertNotIn("Preserve other people natively", controls) - self.assertIn("preserves detected bystanders automatically", controls) + self.assertIn("t('recast.aboutText')", controls) + self.assertIn( + "preserves detected bystanders automatically", + studio["recast"]["aboutText"], + ) def test_recast_discovers_mappings_across_the_selected_timeline(self): launch = _read(_LAUNCH_PATH) @@ -3696,6 +3704,7 @@ def test_recast_discovers_mappings_across_the_selected_timeline(self): client = api_client_source() magic_mask = _read(_MAGIC_MASK_PATH) sam3 = _read(_SAM3_PREPROCESSOR_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn("def _detect_recast_shot_ranges(", launch) self.assertIn("tracking_segments=shot_ranges", launch) @@ -3714,7 +3723,11 @@ def test_recast_discovers_mappings_across_the_selected_timeline(self): ) self.assertIn("and not timeline_scene_reference", scail2) self.assertIn("end_time: editEndTime", controls) - self.assertIn("Not found in the selected timeline.", controls) + self.assertIn("t('recast.notFound')", controls) + self.assertEqual( + studio["recast"]["notFound"], + "Not found in the selected timeline.", + ) self.assertIn("end_time?: number;", client) self.assertIn("tracking_segments=tracking_segments", magic_mask) self.assertIn("timeline_segments =", sam3) @@ -3803,6 +3816,7 @@ def test_recast_relighting_is_hash_pinned_and_opt_in(self): store = _read(_STORE_PATH) controls = _read(_RECAST_CONTROLS_PATH) selector = _read(_LORA_SELECTOR_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn( '_RECAST_RELIGHTING_LORA_FILENAME = "scail2_relighting_lora.safetensors"', launch, @@ -3820,15 +3834,21 @@ def test_recast_relighting_is_hash_pinned_and_opt_in(self): self.assertIn("const recastSinglePhase =", store) self.assertIn("const recastSinglePhase =", selector) self.assertIn("const phases = recastSinglePhase ? 1", selector) - self.assertIn("Match lighting", controls) - self.assertIn("official SCAIL-2 Relighting LoRA", controls) - self.assertIn("single strength control", controls) + self.assertIn("t('recast.matchLighting')", controls) + self.assertIn("t('recast.aboutLightingText')", controls) + self.assertEqual(studio["recast"]["matchLighting"], "Match lighting") + self.assertIn( + "official SCAIL-2 Relighting LoRA", + studio["recast"]["aboutLightingText"], + ) + self.assertIn("single strength control", studio["recast"]["aboutLightingText"]) def test_recast_isolates_reference_background_by_default(self): launch = _read(_LAUNCH_PATH) scail2 = _read(_SCAIL2_PATH) store = _read(_STORE_PATH) controls = _read(_RECAST_CONTROLS_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn('body.get("isolate_reference") is not False', launch) self.assertIn('"scail2_isolate_reference_background": False', launch) self.assertIn('"scail2_reference_alpha_path"', launch) @@ -3841,7 +3861,8 @@ def test_recast_isolates_reference_background_by_default(self): self.assertIn("editRecastIsolateReference: true", store) self.assertIn("isolate_reference: true", store) self.assertNotIn("Isolate replacement from reference background", controls) - self.assertIn("HocusPocus isolates references", controls) + self.assertIn("t('recast.aboutText')", controls) + self.assertIn("HocusPocus isolates references", studio["recast"]["aboutText"]) def test_recast_uses_identity_latents_and_trims_motion_preroll(self): launch = _read(_LAUNCH_PATH) @@ -3905,10 +3926,12 @@ def test_recast_default_prefers_dedicated_fast_scail2(self): launch = _read(_LAUNCH_PATH) store = _read(_STORE_PATH) controls = _read(_RECAST_CONTROLS_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn('body.get("model_type") or _RECAST_FAST_MODEL_TYPE', launch) self.assertIn("? 'scail2_14B_recast_fast'", store) - self.assertIn("Fast is recommended (8 steps)", controls) - self.assertIn("HQ uses the full 40-step schedule", controls) + self.assertIn("t('recast.aboutText')", controls) + self.assertIn("Fast is recommended (8 steps)", studio["recast"]["aboutText"]) + self.assertIn("HQ uses the full 40-step schedule", studio["recast"]["aboutText"]) self.assertIn("initialModelType === 'scail2_14B_fast'", store) self.assertIn("editSubMode: 'restyle' as const", store) self.assertIn("editSubMode: 'recast' as const", store) @@ -3996,11 +4019,16 @@ def test_scail_edit_advanced_panel_only_shows_effective_settings(self): advanced = _read(_ADVANCED_SETTINGS_PATH) launch = _read(_LAUNCH_PATH) store = _read(_STORE_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn("const isScailEdit = isRecast || isRepaint", advanced) self.assertIn("{showInferenceSteps && (", advanced) self.assertIn("{showGuidanceScale && (", advanced) - self.assertIn("Fast keeps its distilled CFG 1 recipe", advanced) + self.assertIn("t('advanced.fastCfg')", advanced) + self.assertIn( + "Fast keeps its distilled CFG 1 recipe", + studio["advanced"]["fastCfg"], + ) self.assertIn( "!isAudio && !isScailEdit && ", advanced, @@ -4069,6 +4097,7 @@ def test_recast_ui_keeps_mapping_cards_and_moves_edited_frame_to_repaint(self): store = _read(_STORE_PATH) controls = _read(_RECAST_CONTROLS_PATH) repaint_controls = _read(_REPAINT_CONTROLS_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn("editRecastMappings: RecastCharacterMapping[]", store) self.assertIn("character_mappings: recastMappings.map", store) self.assertIn("additional_ref_image_paths: mapping.additionalRefs", store) @@ -4076,23 +4105,29 @@ def test_recast_ui_keeps_mapping_cards_and_moves_edited_frame_to_repaint(self): self.assertIn("const refUrl = api.getFileUrl(refName)", store) self.assertNotIn("sendFrameToImageMode('recast')", controls) self.assertIn("sendFrameToImageMode('repaint')", repaint_controls) - self.assertIn("Characters ({mappings.length}/5)", controls) - self.assertIn("Add character", controls) - self.assertIn("More views", controls) - self.assertIn("Prepared references", controls) + self.assertIn("t('recast.characters'", controls) + self.assertIn("t('recast.addCharacter')", controls) + self.assertIn("t('recast.moreViews')", controls) + self.assertIn("t('recast.prepared')", controls) + self.assertEqual(studio["recast"]["characters"], "Characters ({{count}}/5)") + self.assertEqual(studio["recast"]["addCharacter"], "Add character") + self.assertEqual(studio["recast"]["moreViews"], "More views") + self.assertEqual(studio["recast"]["prepared"], "Prepared references") self.assertIn("editRecastRefAligned: boolean", store) self.assertIn( "reference_aligned_to_source: mapping.referenceAlignedToSource", store, ) self.assertNotIn("Reference is a full edited copy of the selected first frame", controls) - self.assertIn("Edited first frame", repaint_controls) + self.assertIn("t('repaint.editedFrame')", repaint_controls) + self.assertEqual(studio["repaint"]["editedFrame"], "Edited first frame") def test_recast_auto_face_detail_is_previewed_saved_and_automatic(self): launch = _read(_LAUNCH_PATH) store = _read(_STORE_PATH) controls = _read(_RECAST_CONTROLS_PATH) client = api_client_source() + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn('body.get("auto_face_detail") is not False', launch) self.assertIn( @@ -4107,8 +4142,10 @@ def test_recast_auto_face_detail_is_previewed_saved_and_automatic(self): store, ) self.assertNotIn("Automatically add a face-detail view", controls) - self.assertIn("adds a face-detail view", controls) - self.assertIn("'Face detail'", controls) + self.assertIn("t('recast.aboutText')", controls) + self.assertIn("adds a face-detail view", studio["recast"]["aboutText"]) + self.assertIn("t('recast.faceDetail')", controls) + self.assertEqual(studio["recast"]["faceDetail"], "Face detail") self.assertIn("auto_face_detail?: boolean", client) self.assertIn("'auto_face_detail'", client) @@ -4146,6 +4183,7 @@ def test_repaint_reuses_scail_animate_and_is_a_first_class_edit_mode(self): controls = _read(_REPAINT_CONTROLS_PATH) toggle = _read(_EDIT_SUBMODE_PATH) client = api_client_source() + studio = json.loads(_read(_STUDIO_EN_PATH)) self.assertIn('@api.post("/api/v1/repaint")', launch) self.assertIn('@api.post("/api/v1/repaint/preview")', launch) @@ -4164,20 +4202,27 @@ def test_repaint_reuses_scail_animate_and_is_a_first_class_edit_mode(self): self.assertIn("target.anchor === 'repaint'", store) self.assertIn("submitRepaint", client) self.assertIn("repaintPreview", client) - self.assertIn("{ value: 'restyle', label: 'Repaint' }", toggle) - self.assertNotIn("{ value: 'restyle', label: 'Restyle', experimental: true }", toggle) - self.assertIn("Fast is recommended (6 steps)", controls) - self.assertIn("Track changed regions", controls) - self.assertIn("source-to-edited-frame mapping", controls) + self.assertIn("{ value: 'restyle', labelKey: 'editSubModes.restyle' }", toggle) + self.assertNotIn("{ value: 'restyle', labelKey: 'editSubModes.restyle', experimental: true }", toggle) + self.assertEqual(studio["editSubModes"]["restyle"], "Repaint") + self.assertIn("t('repaint.aboutText')", controls) + self.assertIn("Fast is recommended (6 steps)", studio["repaint"]["aboutText"]) + self.assertIn("t('repaint.trackRegions')", controls) + self.assertEqual(studio["repaint"]["trackRegions"], "Track changed regions") + self.assertIn("t('repaint.aboutRegionsText')", controls) + self.assertIn( + "source-to-edited-frame mapping", + studio["repaint"]["aboutRegionsText"], + ) def test_primary_edit_modes_use_the_product_order(self): toggle = _read(_EDIT_SUBMODE_PATH) ordered_modes = ( - "{ value: 'retake', label: 'Retake' }", - "{ value: 'edit_anything', label: 'Edit Anything' }", - "{ value: 'outpaint', label: 'Outpaint' }", - "{ value: 'restyle', label: 'Repaint' }", - "{ value: 'recast', label: 'Recast' }", + "{ value: 'retake', labelKey: 'editSubModes.retake' }", + "{ value: 'edit_anything', labelKey: 'editSubModes.editAnything' }", + "{ value: 'outpaint', labelKey: 'editSubModes.outpaint' }", + "{ value: 'restyle', labelKey: 'editSubModes.restyle' }", + "{ value: 'recast', labelKey: 'editSubModes.recast' }", ) positions = [toggle.index(mode) for mode in ordered_modes] self.assertEqual(positions, sorted(positions)) @@ -4188,6 +4233,7 @@ def test_recast_and_repaint_use_compact_copy_with_accessible_help(self): resolution = _read(_SCAIL_RESOLUTION_SELECTOR_PATH) tooltip = _read(_INFO_TOOLTIP_PATH) prompt = _read(_PROMPT_INPUT_PATH) + studio = json.loads(_read(_STUDIO_EN_PATH)) for controls in (recast, repaint, resolution): self.assertIn(" str: @@ -27,36 +30,53 @@ def test_client_created_series_entities_use_browser_uuid(): def test_setup_has_required_aura_explicit_models_and_canvas_choices(): setup = source("SeriesSetupPanel.tsx") fields = source("components.tsx") + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) assert "shadow-[0_0_18px" in fields - assert "Prepare canon text" in setup - assert "Prepare canon + up to 4 images" in setup - assert "will not silently select or download a recommended model" in setup + assert "t('setup.prepareText')" in setup + assert catalog["setup"]["prepareText"] == "Prepare canon text" + assert "t('setup.prepareImages')" in setup + assert catalog["setup"]["prepareImages"] == "Prepare canon + up to 4 images" + assert "t('setup.needImageModel')" in setup + assert "will not silently select or download a recommended model" in catalog["setup"]["needImageModel"] assert "minimax_h3" in setup and "minimax_h3_full" in setup assert "480p" in setup and "720p" in setup - assert "Landscape" in setup and "Portrait" in setup - assert "Fill from a known series · one click" in setup + assert "t('providers.landscape')" in setup and "t('providers.portrait')" in setup + assert catalog["providers"]["landscape"].startswith("Landscape") + assert catalog["providers"]["portrait"].startswith("Portrait") + assert "t('setup.knownTitle')" in setup + assert catalog["setup"]["knownTitle"] == "Fill from a known series · one click" assert "bootstrapKnownSeries: true" in setup and "autoApply: true" in setup - assert "not live web research" in setup - assert "Nothing has been approved automatically" in setup + assert "t('setup.knownDisclaimer')" in setup + assert "not live web research" in catalog["setup"]["knownDisclaimer"] + assert "t('setup.draftReview')" in setup + assert "Nothing has been approved automatically" in catalog["setup"]["draftReview"] def test_shot_ui_exposes_exact_manifest_and_persistent_manual_policy(): shots = source("SeriesShotsPanel.tsx") - assert "Exact routed manifest" in shots + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) + assert "t('shots.manifestTitle')" in shots + assert catalog["shots"]["manifestTitle"] == "Exact routed manifest" assert "manualIncludeAssetIds" in shots assert "manualExcludeAssetIds" in shots assert "composed_start_frame" in shots and "composed_end_frame" in shots - assert "Render selected" in shots and "Render missing" in shots and "Retry failed" in shots - assert "Select all" in shots and "Clear selection" in shots - assert "I understand · enable dialogue rendering" in shots + assert "t('shots.renderSelected'" in shots and "t('shots.renderMissing')" in shots and "t('shots.retryFailed')" in shots + assert catalog["shots"]["renderSelected"].startswith("Render selected") + assert catalog["shots"]["renderMissing"] == "Render missing" + assert catalog["shots"]["retryFailed"] == "Retry failed" + assert "t('shots.selectAll'" in shots and "t('shots.clearSelection')" in shots + assert "t('shots.lipSyncEnable')" in shots + assert catalog["shots"]["lipSyncEnable"] == "I understand · enable dialogue rendering" assert "onAcknowledgeLipSync" in shots def test_canon_facts_can_be_removed_individually(): canon = source("SeriesCanonPanel.tsx") - assert 'title="Current facts"' in canon + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) + assert "title={t('canon.currentFacts')}" in canon + assert catalog["canon"]["currentFacts"] == "Current facts" assert "currentFacts: series.canon.currentFacts.filter(item => item.id !== fact.id)" in canon - assert "aria-label={`Delete fact: ${fact.description}`}" in canon + assert "aria-label={t('canon.deleteFact', { description: fact.description })}" in canon def test_review_is_thumbnail_first_and_exposes_ordered_editable_attempt_history(): @@ -94,23 +114,31 @@ def test_story_productions_have_an_in_place_ordered_clip_timeline(): def test_backend_authority_selection_restore_and_recovery_cards_are_wired(): store = source("store.ts") panel = source("SeriesLabPanel.tsx") + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) assert "fetchSeriesLibrary" in store assert "maestro-series-lab-active" in store assert "seriesId, episodeId" in store assert "fetchSeriesPlanRecovery" in store and "fetchSeriesRenderRecovery" in store - assert "Recoverable Series Lab work" in panel - assert ">Resume<" in panel and ">Discard state<" in panel + assert "t('recovery.title')" in panel + assert catalog["recovery"]["title"] == "Recoverable Series Lab work" + assert "t('chrome.resume')" in panel and "t('chrome.discardState')" in panel def test_episode_proposal_uses_readable_cards_and_manual_editing(): panel = source("SeriesEpisodePanel.tsx") review = source("SeriesEpisodeProposalReview.tsx") client = api_client_source() + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) assert "SeriesEpisodeProposalReview" in panel - assert "Generated proposal — review and edit" in review - assert "Internal IDs remain protected" in review - assert 'title="Outline"' in review and 'title="Script"' in review and 'title="Timed shots"' in review - assert "Reset edits" in review and "Apply reviewed" in review - assert "Generation prompt" in review and "Visible characters" in review - assert "Technical JSON (optional, read-only)" in review + assert "t('proposal.title')" in review + assert catalog["proposal"]["title"] == "Generated proposal — review and edit" + assert "t('proposal.description')" in review + assert "Internal IDs remain protected" in catalog["proposal"]["description"] + assert "title={t('proposal.outline')}" in review + assert "title={t('proposal.script')}" in review + assert "title={t('proposal.timedTitle')}" in review + assert "t('proposal.reset')" in review and "t('proposal.apply')" in review + assert "t('proposal.prompt')" in review and "t('proposal.visibleCharacters')" in review + assert "t('proposal.technicalJson')" in review + assert catalog["proposal"]["technicalJson"] == "Technical JSON (optional, read-only)" assert "JSON.stringify(episodeResult ? { episodeResult } : {})" in client diff --git a/tests/test_story_lab_audio_ui.py b/tests/test_story_lab_audio_ui.py index 622b8670c..b48ec293f 100644 --- a/tests/test_story_lab_audio_ui.py +++ b/tests/test_story_lab_audio_ui.py @@ -1,5 +1,7 @@ """Source-level contracts for Story Lab music import and cancellation UI.""" +import json + from pathlib import Path from tests.api_client_source import api_client_source @@ -130,8 +132,10 @@ def test_story_lab_status_polling_survives_transient_mobile_disconnects(): def test_music_video_confirmation_names_the_frozen_video_model(): source = STORY.read_text(encoding="utf-8") + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) - assert "Video model: ${selectedFilmVideoModel?.name || filmVideoModel} (${filmVideoModel})" in source + assert "t('notice.generateMusicVideoConfirm'" in source + assert "Video model: {{model}} ({{modelId}})" in catalog["notice"]["generateMusicVideoConfirm"] assert "Video model selection did not settle" in source assert "Director did not return a pipeline ID" in source @@ -151,6 +155,7 @@ def test_story_assets_support_reviewed_non_destructive_style_variants(): panel = STORY.read_text(encoding="utf-8") types = STORY_TYPES.read_text(encoding="utf-8") model = STORY_MODEL.read_text(encoding="utf-8") + catalog_data = json.loads(CATALOG_EN.read_text(encoding="utf-8")) assert "approval: StoryApprovalState" in types assert "derivedFromAssetId?: string" in types @@ -162,7 +167,8 @@ def test_story_assets_support_reviewed_non_destructive_style_variants(): assert "Style conversion model" in catalog assert "MiniMax Image-01 · characters only" in catalog assert "Install selected local editor" in catalog - assert "Review and approve only the images Director should use" in panel + assert "t('notice.styleVariantsCreated'" in panel + assert "Review and approve only the images Director should use" in catalog_data["notice"]["styleVariantsCreated_other"] assert "approval: item.approval === 'draft' ? 'draft' : 'approved'" in model @@ -171,6 +177,7 @@ def test_story_library_can_bulk_remove_only_selected_drafts(): deletion = panel.split("const deleteSelectedDraftAssets", 1)[1].split( "const styleUsesMiniMax", 1, )[0] + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) assert "snapshot.assets[id]?.approval === 'draft'" in deletion assert "current.assets[id]?.approval === 'draft'" in deletion @@ -178,7 +185,8 @@ def test_story_library_can_bulk_remove_only_selected_drafts(): assert "location.referenceAssetIds.filter" in deletion assert "character.referenceAssetIds.filter" in deletion assert "delete current.assets[id]" in deletion - assert "Generated files remain in Gallery" in deletion + assert "t('notice.draftsRemoved'" in deletion + assert "Generated files remain in Gallery" in catalog["notice"]["draftsRemoved_other"] assert "Delete selected Draft" in CATALOG_EN.read_text(encoding="utf-8") assert "visualAssetsNewestFirst" in panel assert "Newest images appear first" in CATALOG_EN.read_text(encoding="utf-8") @@ -217,10 +225,12 @@ def test_story_style_converter_warns_about_photo_to_photo_noops_and_honors_reque def test_story_style_conversion_uses_true_qwen_edit_semantics_for_scenes(): panel = STORY.read_text(encoding="utf-8") generation = IMAGE_GENERATION.read_text(encoding="utf-8") + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) assert "referenceMode: 'edit'" in panel assert "resolution: STYLE_RESOLUTION_BY_ASPECT[aspectRatio]" in panel - assert "MiniMax Image-01 references are documented for character identity only" in panel + assert "t('notice.minimaxCharactersOnly')" in panel + assert "MiniMax Image-01 references are documented for character identity only" in catalog["notice"]["minimaxCharactersOnly"] assert "options.referenceMode === 'edit'" in generation assert "? 'KI'" in generation assert "referenceParams.model_mode = 0" in generation diff --git a/tests/test_story_lab_trailer_ui.py b/tests/test_story_lab_trailer_ui.py index 0beaae3eb..4f98ce510 100644 --- a/tests/test_story_lab_trailer_ui.py +++ b/tests/test_story_lab_trailer_ui.py @@ -1,5 +1,7 @@ """Source contracts for Story Lab's cinematic trailer creator.""" +import json + from pathlib import Path @@ -141,9 +143,11 @@ def test_trailer_supports_text_only_direct_video_without_visual_inputs(): def test_direct_trailer_cast_approval_does_not_require_identity_images(): panel = PANEL.read_text(encoding="utf-8") approval = panel.split("const approve =", 1)[1].split("const isApproved", 1)[0] + catalog = json.loads(CATALOG_EN.read_text(encoding="utf-8")) assert "const requiresVisualIdentities = !directVideo" in approval - assert "Character descriptions approved. Direct-video mode does not require identity images." in approval + assert "t('notice.descriptionsApproved')" in approval + assert catalog["notice"]["descriptionsApproved"] == "Character descriptions approved. Direct-video mode does not require identity images." assert "project.projectType === 'trailer'" in panel assert "? trailerProductionIssues" in panel assert "requiresVisualIdentities={!directVideo}" in panel diff --git a/tests/test_voice_reference_settings.py b/tests/test_voice_reference_settings.py index 71de7429a..9b528a20f 100644 --- a/tests/test_voice_reference_settings.py +++ b/tests/test_voice_reference_settings.py @@ -1,5 +1,6 @@ """Regression coverage for Voice Reference and beta-feature defaults.""" +import json import os import unittest @@ -14,6 +15,9 @@ "SettingsDrawer", "ServicesSettingsPanel.tsx", ) +_SETTINGS_EN_PATH = os.path.join( + _ROOT, "ui", "src", "i18n", "locales", "en", "settings.json", +) def _read(path): @@ -36,17 +40,20 @@ def test_voice_reference_defaults_on_while_beta_features_default_off(self): def test_voice_reference_setting_is_not_behind_beta_feature_gate(self): panel = _read(_SERVICES_PANEL_PATH) + settings = json.loads(_read(_SETTINGS_EN_PATH)) block_start = panel.index("{/* Voice Reference (ID-LoRA)") block_end = panel.index("", block_start) voice_reference_block = panel[block_start:block_end] - self.assertIn("Voice Reference (ID-LoRA)", voice_reference_block) + self.assertIn("t('services.voiceTitle')", voice_reference_block) + self.assertEqual(settings["services"]["voiceTitle"], "Voice Reference (ID-LoRA)") self.assertIn("voice_reference_enabled", voice_reference_block) self.assertNotIn("show_experimental", voice_reference_block) self.assertNotIn("Experimental", voice_reference_block) - beta_copy = panel[panel.index("Show in-development features"):] - self.assertNotIn("Voice Reference", beta_copy) + beta_copy = panel[panel.index("t('services.betaToggle')"):] + self.assertEqual(settings["services"]["betaToggle"], "Show in-development features") + self.assertNotIn("services.voiceTitle", beta_copy) if __name__ == "__main__": diff --git a/ui/scripts/check-i18n-catalogs.mjs b/ui/scripts/check-i18n-catalogs.mjs index 2fb4e2986..5406b9479 100644 --- a/ui/scripts/check-i18n-catalogs.mjs +++ b/ui/scripts/check-i18n-catalogs.mjs @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo', 'storyLab', 'director', 'seriesLab', 'videoEditor', 'workspaces', 'styleSheet', 'projects', 'auditDev'] +const NAMESPACES = ['common', 'navigation', 'settings', 'wizard', 'activity', 'extraInfo', 'storyLab', 'director', 'seriesLab', 'videoEditor', 'workspaces', 'styleSheet', 'projects', 'auditDev', 'scene3d', 'shell', 'characters', 'comics', 'studio'] const LANGUAGES = ['en', 'es'] function load(language, namespace) { diff --git a/ui/src/components/DirectorDashboard/DirectorDashboard.tsx b/ui/src/components/DirectorDashboard/DirectorDashboard.tsx index 508f84031..72b83feb0 100644 --- a/ui/src/components/DirectorDashboard/DirectorDashboard.tsx +++ b/ui/src/components/DirectorDashboard/DirectorDashboard.tsx @@ -5,6 +5,7 @@ import { getFileUrl } from '../../api/client' import { getOutputReference } from '../../lib/outputReference' import type { H3SegmentState, PipelineClipState, SavedPipelineState } from '../../types' import { ModalShell } from '../common/ModalShell' +import i18n, { useUiTranslation } from '../../i18n' /** Safely coerce any value to a displayable string */ function safeStr(val: unknown): string { @@ -19,33 +20,39 @@ function fileLabel(path: string): string { } /** Error boundary to prevent the productions view crashing on bad saved data. */ +function DashboardCrash({ error, onRetry }: { error: string; onRetry: () => void }) { + const { t } = useUiTranslation('director') + return ( +
+

{t('dashboard.crash', { error })}

+ +
+ ) +} + class DashboardErrorBoundary extends Component<{ children: ReactNode }, { error: string | null }> { state = { error: null as string | null } static getDerivedStateFromError(err: Error) { return { error: err.message } } render() { if (this.state.error) { - return ( -
-

Video workflows error: {this.state.error}

- -
- ) + return this.setState({ error: null })} /> } return this.props.children } } function formatTime(sec: number | null): string { - if (!sec) return '--' - if (sec < 60) return `${Math.round(sec)}s` + if (!sec) return i18n.t('dashboard.noTime', { ns: 'director' }) + if (sec < 60) return i18n.t('dashboard.seconds', { ns: 'director', count: Math.round(sec) }) const m = Math.floor(sec / 60) const s = Math.round(sec % 60) - return `${m}m ${s}s` + return i18n.t('dashboard.minutesSeconds', { ns: 'director', minutes: m, seconds: s }) } function formatDate(ts: number): string { - return new Date(ts * 1000).toLocaleString(undefined, { + const locale = i18n.language === 'es' ? 'es-ES' : 'en-US' + return new Date(ts * 1000).toLocaleString(locale, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }) } @@ -66,29 +73,32 @@ function completedH3Segments(clip: PipelineClipState): number { } function PipelineProgressBar({ pipeline }: { pipeline: SavedPipelineState }) { + const { t } = useUiTranslation('director') const fallbackImageTime = pipeline.clips.reduce((sum, c) => sum + (c.image_gen_time_sec || 0), 0) || null const fallbackVideoTime = pipeline.clips.reduce((sum, c) => sum + (c.video_gen_time_sec || 0), 0) || null const llmPassCount = pipeline.llm_log?.passes?.length || (pipeline.llm_log ? 1 : 0) + const readyImages = pipeline.clips.filter(c => Boolean(c.start_image_filename)).length + const readyVideos = pipeline.clips.filter(c => Boolean(c.video_filename)).length const phases = [ { key: 'planning', - label: 'Prompts', + label: t('dashboard.prompts'), time: pipeline.prompt_generation_time_sec ?? pipeline.llm_log?.planning_time_sec, - detail: `${llmPassCount} LLM pass${llmPassCount === 1 ? '' : 'es'}`, + detail: t('dashboard.llmPass', { count: llmPassCount }), }, { key: 'images', - label: 'Images / preparation', + label: t('dashboard.imagesPrep'), time: pipeline.image_generation_time_sec ?? fallbackImageTime, - detail: `${pipeline.clips.filter(c => Boolean(c.start_image_filename)).length}/${pipeline.clips.length} ready`, + detail: t('dashboard.ready', { ready: readyImages, total: pipeline.clips.length }), }, { key: 'video', - label: 'Videos + final assembly', + label: t('dashboard.videosAssembly'), time: pipeline.video_generation_time_sec ?? fallbackVideoTime, detail: pipeline.video_model === 'minimax_h3' - ? `${pipeline.clips.reduce((sum, clip) => sum + completedH3Segments(clip), 0)} segments` - : `${pipeline.clips.filter(c => Boolean(c.video_filename)).length}/${pipeline.clips.length} clips`, + ? t('dashboard.segmentsCount', { count: pipeline.clips.reduce((sum, clip) => sum + completedH3Segments(clip), 0) }) + : t('dashboard.clipsReady', { ready: readyVideos, total: pipeline.clips.length }), }, ] const timedTotal = phases.reduce((s, p) => s + (p.time || 0), 0) || 1 @@ -119,26 +129,27 @@ function PipelineProgressBar({ pipeline }: { pipeline: SavedPipelineState }) {
{isComplete ? : } - Total elapsed + {t('dashboard.totalElapsed')}
{formatTime(pipeline.total_time_sec)}
-
Since production started
+
{t('dashboard.sinceStarted')}
{pipeline.assembly_time_sec != null && (
- Latest re-join: {formatTime(pipeline.assembly_time_sec)} - {pipeline.assembly_count ? ` · ${pipeline.assembly_count} re-join${pipeline.assembly_count === 1 ? '' : 's'}` : ''} + {t('dashboard.latestRejoin', { time: formatTime(pipeline.assembly_time_sec) })} + {pipeline.assembly_count ? t('dashboard.rejoinCount', { count: pipeline.assembly_count }) : ''}
)}

- Image and video work can overlap; total elapsed is wall-clock time and may be lower than the sum of stages. + {t('dashboard.overlapNote')}

) } function LlmPassView({ pass: p, index }: { pass: { pass: string; system_prompt: string; user_prompt?: string; response_text: string; thinking_text?: string | null }; index: number }) { + const { t } = useUiTranslation('director') const [showSystem, setShowSystem] = useState(false) const [showUser, setShowUser] = useState(false) const [showResponse, setShowResponse] = useState(false) @@ -147,16 +158,16 @@ function LlmPassView({ pass: p, index }: { pass: { pass: string; system_prompt: return (
-
Pass {index + 1}: {label}
+
{t('dashboard.pass', { n: index + 1, label })}
{showSystem && (
-          {p.system_prompt || '(empty)'}
+          {p.system_prompt || t('dashboard.emptyPrompt')}
         
)} @@ -169,11 +180,11 @@ function LlmPassView({ pass: p, index }: { pass: { pass: string; system_prompt: {showUser && (
-              {p.user_prompt || '(empty)'}
+              {p.user_prompt || t('dashboard.emptyPrompt')}
             
)} @@ -184,7 +195,7 @@ function LlmPassView({ pass: p, index }: { pass: { pass: string; system_prompt: {showThinking && (
@@ -197,11 +208,11 @@ function LlmPassView({ pass: p, index }: { pass: { pass: string; system_prompt:
       
       {showResponse && (
         
-          {p.response_text || '(empty)'}
+          {p.response_text || t('dashboard.emptyPrompt')}
         
)}
@@ -209,8 +220,9 @@ function LlmPassView({ pass: p, index }: { pass: { pass: string; system_prompt: } function LlmLogPanel({ pipeline }: { pipeline: SavedPipelineState }) { + const { t } = useUiTranslation('director') const log = pipeline.llm_log - if (!log) return

No LLM log captured

+ if (!log) return

{t('dashboard.noLlmLog')}

const passes = log.passes @@ -218,8 +230,8 @@ function LlmLogPanel({ pipeline }: { pipeline: SavedPipelineState }) {
- {log.provider}/{log.model_id || 'unknown'} - ({passes?.length || 1} pass{(passes?.length || 1) > 1 ? 'es' : ''}) + {log.provider}/{log.model_id || t('dashboard.unknownModel')} + {t('dashboard.passCount', { count: passes?.length || 1 })} {formatTime(log.planning_time_sec)}
@@ -247,6 +259,8 @@ function H3SegmentCard({ segment, shotIndex, onRerun }: { shotIndex: number onRerun: (segmentIndex: number, prompt: string) => void }) { + const { t } = useUiTranslation('director') + const { t: tCommon } = useUiTranslation('common') const [editing, setEditing] = useState(false) const [prompt, setPrompt] = useState(segment.prompt || '') const [copied, setCopied] = useState(false) @@ -265,16 +279,16 @@ function H3SegmentCard({ segment, shotIndex, onRerun }: { return (
- Segment {segment.index + 1} - {(segment.frames / 24).toFixed(1)}s · seed {segment.seed} + {t('dashboard.segment', { n: segment.index + 1 })} + {t('dashboard.durationSeed', { seconds: (segment.frames / 24).toFixed(1), seed: segment.seed })} { segment.reference_mode === 'direct_video' - ? 'text only · no images' + ? t('dashboard.textOnly') : segment.reference_mode === 'references' - ? 'identity references' - : 'exact first frame' + ? t('dashboard.identityRefs') + : t('dashboard.exactFirstFrame') } - {segment.stale && Needs regeneration} + {segment.stale && {t('dashboard.needsRegen')}}
{segment.filename && (
{editing && ( @@ -333,6 +347,7 @@ function ClipCard({ clip, pipeline, busy = false, onTag, onRerunImage, onRerunVi onRerunVideo: (clipIndex: number, prompt?: string) => void onRerunH3Segment: (clipIndex: number, segmentIndex: number, prompt?: string) => void }) { + const { t } = useUiTranslation('director') const [expandImage, setExpandImage] = useState(false) const [expandVideo, setExpandVideo] = useState(false) const [showPolish, setShowPolish] = useState(false) @@ -363,12 +378,12 @@ function ClipCard({ clip, pipeline, busy = false, onTag, onRerunImage, onRerunVi {/* Header */}
- Shot {clip.index + 1} + {t('dashboard.shot', { n: clip.index + 1 })} {(clip.planned_clip as unknown as Record | null)?.duration_sec ? ( - ({Math.round((clip.planned_clip as unknown as Record).duration_sec as number)}s) + {t('dashboard.shotDuration', { count: Math.round((clip.planned_clip as unknown as Record).duration_sec as number) })} ) : null} {clip.window_count > 1 && ( - {clip.window_count}W + {t('dashboard.windowCount', { count: clip.window_count })} )}
@@ -382,13 +397,13 @@ function ClipCard({ clip, pipeline, busy = false, onTag, onRerunImage, onRerunVi
@@ -400,7 +415,7 @@ function ClipCard({ clip, pipeline, busy = false, onTag, onRerunImage, onRerunVi {/* Thumbnail */}
{clip.start_image_filename ? ( - {`Shot ) : clip.video_filename ? (