Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/api/src/hcs_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@
ProjectSummary,
ProviderCapabilityDescriptor,
ProviderSettings,
QualityReport,
SourceMaterial,
StateFirstTeacherSummary,
VideoProviderSettings,
VisualThemeCatalog,
VisualThemeSelectionUpdate,
)
from .parser import parse_source
from .source_understanding import OCRPolicy, get_engine_status
Expand All @@ -76,6 +79,12 @@
from .pipeline import generate_lesson_blueprint, generate_project_media
from .pipeline import render_and_check, run_full_pipeline, write_blueprint_artifacts, write_spec_artifacts
from .pptx_exporter import export_editable_pptx
from .presentation_theme import (
persist_visual_theme_selection,
recommend_visual_theme,
visual_theme_catalog,
visual_theme_selection_for_project,
)
from .storage import (
PROJECTS_DIR,
RUNTIME_DIR,
Expand Down Expand Up @@ -1071,6 +1080,11 @@ def component_registry() -> dict:
return load_component_registry()


@app.get("/api/visual-themes", response_model=VisualThemeCatalog)
def read_visual_theme_catalog() -> VisualThemeCatalog:
return visual_theme_catalog()


@app.post("/api/projects/upload", response_model=ProjectState)
async def upload_project(file: UploadFile = File(...), engine: str | None = Query(default=None)) -> ProjectState:
if not file.filename:
Expand Down Expand Up @@ -1162,6 +1176,58 @@ def read_project(project_id: str) -> ProjectState:
return get_project_state(project_id)


@app.put("/api/projects/{project_id}/visual-theme", response_model=ProjectState)
def save_visual_theme(
project_id: str,
update: VisualThemeSelectionUpdate,
expected_revision: int | None = Query(default=None),
) -> ProjectState:
root = _assert_project(project_id)
_assert_expected_revision(project_id, expected_revision)
profile = read_model(project_id, "lesson_profile.json", LessonProfile)
blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint)
current = visual_theme_selection_for_project(root, profile=profile, blueprint=blueprint)
recommended, reason = recommend_visual_theme(profile, blueprint)
target_theme_id = recommended if update.mode == "auto" else update.selected_theme_id
if update.mode == "manual" and target_theme_id is None:
raise HTTPException(status_code=422, detail={
"code": "visual_theme_selection_invalid",
"message": "A supported visual theme is required for manual mode.",
})
no_change = (
current.mode == update.mode
and current.selected_theme_id == target_theme_id
and (update.mode != "auto" or current.recommendation_reason == reason)
)
if no_change:
return get_project_state(project_id)
try:
selection = persist_visual_theme_selection(
root,
mode=update.mode,
selected_theme_id=target_theme_id,
profile=profile,
blueprint=blueprint,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail={
"code": "visual_theme_selection_invalid",
"message": str(exc),
}) from exc
if selection.selected_theme_id != current.selected_theme_id and (
(root / "courseware/lesson.html").is_file()
or read_model(project_id, "quality_report.json", QualityReport) is not None
or latest_export_path(project_id) is not None
):
invalidate_downstream(
project_id,
"visual_theme",
"Visual theme changed; render, quality, and export must be refreshed.",
)
bump_project_revision(project_id)
return get_project_state(project_id)


@app.get("/api/projects/{project_id}/artifacts", response_model=ArtifactTree)
def read_project_artifacts(project_id: str) -> ArtifactTree:
_assert_project(project_id)
Expand Down
56 changes: 32 additions & 24 deletions apps/api/src/hcs_api/media.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import html
import json
import math
import wave
Expand All @@ -15,12 +14,14 @@
from .models import (
AssetCandidate, AssetFile, AssetManifest, GeneratedImage, GeneratedImageFailure,
IllustrationRequest, LessonBlueprint, PresentationTheme, ProviderSettings,
VideoGenerationRequestPlan,
)
from .presentation_theme import (
THEME_DECISION_PATH, THEME_SELECTION_PATH, persist_theme_decision,
resolve_presentation_theme,
THEME_DECISION_PATH, THEME_SELECTION_PATH, VIDEO_REQUEST_PATH,
persist_theme_decision, resolve_presentation_theme, video_generation_requests,
visual_theme_state_for_project,
)
from .providers import ProviderError, _llm_enabled, generate_openai_image, generate_openai_tts
from .providers import ProviderError, _llm_enabled, generate_openai_image, generate_openai_tts, provider_capability_catalog
from .raster_provider import (
ProviderImagePayload, RasterProviderError,
generate_experimental_raster_image,
Expand Down Expand Up @@ -57,14 +58,16 @@ def generate_placeholder_media(
path.write_text(render_scene_spec(spec, presentation_theme=theme), encoding="utf-8")
path.with_suffix(".scene.json").write_text(json.dumps(spec, ensure_ascii=False), encoding="utf-8")
else:
path.write_text(_placeholder_svg(slide.media_requirements.image_prompt, slide.id), encoding="utf-8")
path.write_text(_placeholder_svg(slide.media_requirements.image_prompt, slide.id, theme), encoding="utf-8")
images.append(
AssetFile(
id=slide.media_requirements.image_key,
kind="image",
path=f"assets/images/{filename}",
prompt=slide.media_requirements.image_prompt,
origin_media_requirement_ids=[slide.media_requirements.image_key] if preserve_media_origin_trace else [],
presentation_theme_id=theme.theme_id if theme else None,
presentation_theme_version=theme.version if theme else None,
)
)
if slide.media_requirements.audio_key and slide.media_requirements.audio_text:
Expand Down Expand Up @@ -156,11 +159,16 @@ def generate_configured_media(
for slide in blueprint.slides
if slide.media_requirements.image_key and slide.media_requirements.media_kind != "svg_illustration"
}
video_requested = any(
slide.media_requirements.video_key and slide.media_requirements.video_scene_prompt
for slide in blueprint.slides
)
# Raster art and presentation chrome must resolve the same project theme
# before provider prompts are created; otherwise the exporter defaults to
# blue while an unthemed provider is free to choose an unrelated palette.
theme_requested = (
bool(raster_keys)
or video_requested
or (project_root / THEME_SELECTION_PATH).is_file()
or (project_root / THEME_DECISION_PATH).is_file()
)
Expand All @@ -169,7 +177,7 @@ def generate_configured_media(
if theme_requested:
previous_manifest = AssetManifest(images=list(previous.values()))
decision = resolve_presentation_theme(
project_root, lesson_title=blueprint.lesson_title, manifest=previous_manifest,
project_root, lesson_title=blueprint.lesson_title, blueprint=blueprint, manifest=previous_manifest,
)
theme = decision.theme
manifest = generate_placeholder_media(project_root, blueprint, preserve_media_origin_trace, theme=theme)
Expand Down Expand Up @@ -205,6 +213,22 @@ def generate_configured_media(
_assert_provider_media_success(manifest, settings, raster_keys)
if decision is not None:
persist_theme_decision(project_root, decision, manifest)
theme_state = visual_theme_state_for_project(
project_root,
blueprint=blueprint,
manifest=manifest,
provider_catalog=provider_capability_catalog(settings),
provider_settings=settings,
)
video_support = next(item for item in theme_state.provider_support if item.capability == "video")
requests = video_generation_requests(blueprint, decision.theme, video_support)
if requests:
request_path = project_root / VIDEO_REQUEST_PATH
request_path.parent.mkdir(parents=True, exist_ok=True)
request_path.write_text(
VideoGenerationRequestPlan(requests=requests).model_dump_json(by_alias=True, indent=2),
encoding="utf-8",
)
return manifest


Expand Down Expand Up @@ -598,21 +622,5 @@ def _write_tone(path: Path) -> None:
wav.writeframesraw(amplitude.to_bytes(2, byteorder="little", signed=True))


def _placeholder_svg(prompt: str, slide_id: int) -> str:
safe_prompt = html.escape(prompt[:120])
hue = (slide_id * 41) % 360
accent = f"hsl({hue}, 76%, 52%)"
secondary = f"hsl({(hue + 120) % 360}, 62%, 60%)"
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" role="img" aria-label="{safe_prompt}">
<rect width="1200" height="675" fill="#F8FAF7"/>
<circle cx="260" cy="210" r="132" fill="{accent}" opacity="0.22"/>
<circle cx="930" cy="455" r="168" fill="{secondary}" opacity="0.24"/>
<path d="M190 505 C330 360 440 430 560 332 C684 232 792 258 1010 140" fill="none" stroke="{accent}" stroke-width="24" stroke-linecap="round" opacity="0.82"/>
<rect x="252" y="188" width="504" height="324" rx="8" fill="#FFFFFF" stroke="#DCE8E2" stroke-width="4"/>
<rect x="312" y="248" width="168" height="24" rx="8" fill="{accent}" opacity="0.38"/>
<rect x="312" y="306" width="356" height="18" rx="8" fill="#6F8D88" opacity="0.32"/>
<rect x="312" y="356" width="292" height="18" rx="8" fill="#6F8D88" opacity="0.24"/>
<rect x="800" y="220" width="148" height="148" rx="8" fill="{secondary}" opacity="0.48"/>
<path d="M820 366 L874 300 L916 342 L948 306 L948 366 Z" fill="#FFFFFF" opacity="0.82"/>
<circle cx="846" cy="260" r="20" fill="#FFFFFF" opacity="0.82"/>
</svg>"""
def _placeholder_svg(prompt: str, slide_id: int, theme: PresentationTheme | None = None) -> str:
return placeholder_svg(prompt, slide_id, presentation_theme=theme)
118 changes: 118 additions & 0 deletions apps/api/src/hcs_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,14 @@ class LessonBlueprint(BaseModel):
ThemeDecisionSource = Literal[
"ppt_master_auto", "teacher_selected", "inherited_from_existing_assets",
]
VisualThemeId = Literal[
"classroom-clear",
"active-learning",
"warm-story",
"eastern-elegance",
"future-exploration",
]
VisualThemeMode = Literal["auto", "manual"]


class ThemeTypography(BaseModel):
Expand Down Expand Up @@ -349,6 +357,19 @@ class ThemeImageTreatment(BaseModel):
prohibited_traits: list[str] = Field(default_factory=list)


class ThemeVideoTreatment(BaseModel):
"""Provider-neutral direction retained even when video is unavailable."""

model_config = ConfigDict(extra="forbid")

visual_style: str = "clear educational footage"
color_grade: str = "balanced classroom colour"
lighting: str = "soft, readable lighting"
motion_style: str = "stable, restrained camera movement"
subtitle_direction: str = "high-contrast lower-third subtitles within title-safe margins"
prohibited_traits: list[str] = Field(default_factory=list)


class PresentationTheme(BaseModel):
"""Master-derived design decisions; never a provider or pedagogy contract."""

Expand All @@ -364,6 +385,102 @@ class PresentationTheme(BaseModel):
shapes: ThemeShapeLanguage = Field(default_factory=ThemeShapeLanguage)
layout: ThemeLayout = Field(default_factory=ThemeLayout)
image_treatment: ThemeImageTreatment
# Default keeps presentation_theme.v1 files from older projects readable.
video_treatment: ThemeVideoTreatment = Field(default_factory=ThemeVideoTreatment)


class VisualThemeSelection(BaseModel):
"""Persisted teacher decision. ``auto`` resolves to one real preset."""

model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)

schema_: str = Field(default="hanclassstudio.visual_theme_selection.v1", alias="schema")
mode: VisualThemeMode = "auto"
selected_theme_id: VisualThemeId = "classroom-clear"
recommended_theme_id: VisualThemeId | None = None
recommendation_reason: str | None = None
theme_version: str = "1"


class VisualThemeSelectionUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")

mode: VisualThemeMode
selected_theme_id: VisualThemeId | None = None


class VisualThemePreview(BaseModel):
model_config = ConfigDict(extra="forbid")

background: str
surface: str
primary: str
accent: str
text: str
motif: str


class VisualThemePresetSummary(BaseModel):
model_config = ConfigDict(extra="forbid")

theme_id: VisualThemeId
version: str
name_key: str
description_key: str
preview: VisualThemePreview


class VisualThemeCatalog(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)

schema_: str = Field(default="hanclassstudio.visual_theme_catalog.v1", alias="schema")
theme_version: str = "1"
presets: list[VisualThemePresetSummary] = Field(default_factory=list)


class ThemeCapabilitySupport(BaseModel):
model_config = ConfigDict(extra="forbid")

capability: Literal["presentation", "image", "video"]
provider_id: str | None = None
state: Literal["supported", "unsupported", "unavailable", "not_configured"]
theme_metadata_preserved: bool = True
reason: str | None = None


class VisualThemeState(BaseModel):
model_config = ConfigDict(extra="forbid")

selection: VisualThemeSelection
effective_theme_id: VisualThemeId
effective_theme_version: str
media_state: Literal["not_generated", "current", "mixed"] = "not_generated"
mismatched_media_count: int = 0
mismatched_media_ids: list[str] = Field(default_factory=list)
provider_support: list[ThemeCapabilitySupport] = Field(default_factory=list)
regeneration_available: bool = False


class VideoGenerationRequest(BaseModel):
"""Auditable request contract; it does not imply a provider executed it."""

model_config = ConfigDict(extra="forbid")

id: str
prompt: str
provider_id: str | None = None
theme_id: VisualThemeId
theme_version: str
theme_direction: ThemeVideoTreatment
theme_application_state: Literal["supported", "unsupported", "unavailable", "not_configured"]
theme_application_reason: str | None = None


class VideoGenerationRequestPlan(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True)

schema_: str = Field(default="hanclassstudio.video_generation_requests.v1", alias="schema")
requests: list[VideoGenerationRequest] = Field(default_factory=list)


class PresentationThemeDecision(BaseModel):
Expand Down Expand Up @@ -688,6 +805,7 @@ class ProjectState(BaseModel):
artifacts: dict[str, Any] = Field(default_factory=dict)
stale_state: StaleState = Field(default_factory=StaleState)
provider_readiness: list[ProviderCapabilityDescriptor] = Field(default_factory=list)
visual_theme: VisualThemeState | None = None
last_updated_at: str | None = None
quality_state: QualityState | None = None
source_material: SourceMaterial | None = None
Expand Down
Loading
Loading