优化标题逻辑
This commit is contained in:
@@ -30,7 +30,7 @@ class StoryboardConfig:
|
||||
image_workflow: Optional[str] = None # Image workflow filename (None = use default)
|
||||
|
||||
# Frame template
|
||||
frame_template: Optional[str] = None # HTML template name or path (None = use PIL)
|
||||
frame_template: str = "default.html" # HTML template name or path (e.g., "default.html", "modern.html")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,7 +70,7 @@ class ContentMetadata:
|
||||
@dataclass
|
||||
class Storyboard:
|
||||
"""Complete storyboard"""
|
||||
topic: str # Topic or content title
|
||||
title: str # Video title
|
||||
config: StoryboardConfig # Configuration
|
||||
frames: List[StoryboardFrame] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from reelforge.services.tts_service import TTSService
|
||||
from reelforge.services.image import ImageService
|
||||
from reelforge.services.narration_generator import NarrationGeneratorService
|
||||
from reelforge.services.image_prompt_generator import ImagePromptGeneratorService
|
||||
from reelforge.services.frame_composer import FrameComposerService
|
||||
from reelforge.services.title_generator import TitleGeneratorService
|
||||
from reelforge.services.storyboard_processor import StoryboardProcessorService
|
||||
from reelforge.services.video_generator import VideoGeneratorService
|
||||
|
||||
@@ -67,9 +67,9 @@ class ReelForgeCore:
|
||||
# Content generation services
|
||||
self.narration_generator: Optional[NarrationGeneratorService] = None
|
||||
self.image_prompt_generator: Optional[ImagePromptGeneratorService] = None
|
||||
self.title_generator: Optional[TitleGeneratorService] = None
|
||||
|
||||
# Frame processing services
|
||||
self.frame_composer: Optional[FrameComposerService] = None
|
||||
self.storyboard_processor: Optional[StoryboardProcessorService] = None
|
||||
|
||||
# Video generation service (named as verb for direct calling)
|
||||
@@ -98,9 +98,9 @@ class ReelForgeCore:
|
||||
# 2. Initialize content generation services
|
||||
self.narration_generator = NarrationGeneratorService(self)
|
||||
self.image_prompt_generator = ImagePromptGeneratorService(self)
|
||||
self.title_generator = TitleGeneratorService(self)
|
||||
|
||||
# 3. Initialize frame processing services
|
||||
self.frame_composer = FrameComposerService()
|
||||
self.storyboard_processor = StoryboardProcessorService(self)
|
||||
|
||||
# 4. Initialize video generation service
|
||||
|
||||
@@ -11,7 +11,7 @@ from reelforge.services.image import ImageService
|
||||
from reelforge.services.video import VideoService
|
||||
from reelforge.services.narration_generator import NarrationGeneratorService
|
||||
from reelforge.services.image_prompt_generator import ImagePromptGeneratorService
|
||||
from reelforge.services.frame_composer import FrameComposerService
|
||||
from reelforge.services.title_generator import TitleGeneratorService
|
||||
from reelforge.services.storyboard_processor import StoryboardProcessorService
|
||||
from reelforge.services.video_generator import VideoGeneratorService
|
||||
|
||||
@@ -23,7 +23,7 @@ __all__ = [
|
||||
"VideoService",
|
||||
"NarrationGeneratorService",
|
||||
"ImagePromptGeneratorService",
|
||||
"FrameComposerService",
|
||||
"TitleGeneratorService",
|
||||
"StoryboardProcessorService",
|
||||
"VideoGeneratorService",
|
||||
]
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""
|
||||
Frame composer service - Compose image with subtitle overlay
|
||||
|
||||
Simple implementation for MVP: adds subtitle text to generated image.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from reelforge.models.storyboard import StoryboardConfig
|
||||
|
||||
|
||||
class FrameComposerService:
|
||||
"""
|
||||
Frame composer service
|
||||
|
||||
Composes final frame image by adding subtitle text to generated image.
|
||||
For MVP, we keep it simple - just overlay subtitle at bottom.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.font_path = self._get_font_path()
|
||||
|
||||
def _get_font_path(self) -> str:
|
||||
"""Get Chinese font path"""
|
||||
# Try multiple possible font paths
|
||||
possible_fonts = [
|
||||
"/System/Library/Fonts/PingFang.ttc", # macOS PingFang
|
||||
"/System/Library/Fonts/STHeiti Light.ttc", # macOS Heiti
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc", # macOS Hiragino
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", # Linux Noto
|
||||
]
|
||||
|
||||
for font in possible_fonts:
|
||||
if Path(font).exists():
|
||||
logger.debug(f"Using font: {font}")
|
||||
return font
|
||||
|
||||
logger.warning("No Chinese font found, will use default")
|
||||
return None
|
||||
|
||||
async def compose_frame(
|
||||
self,
|
||||
image_path: str,
|
||||
subtitle: str,
|
||||
output_path: str,
|
||||
config: StoryboardConfig
|
||||
) -> str:
|
||||
"""
|
||||
Compose frame image with subtitle overlay
|
||||
|
||||
Args:
|
||||
image_path: Generated image path
|
||||
subtitle: Subtitle text (narration)
|
||||
output_path: Output path for composed image
|
||||
config: Storyboard configuration
|
||||
|
||||
Returns:
|
||||
Path to composed image
|
||||
"""
|
||||
logger.debug(f"Composing frame: {output_path}")
|
||||
|
||||
# Load generated image
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Create canvas with target video size
|
||||
canvas = Image.new('RGB', (config.video_width, config.video_height), 'white')
|
||||
|
||||
# Calculate scaling to fit image in canvas while preserving aspect ratio
|
||||
# Leave space at top (100px) and bottom (300px for subtitle)
|
||||
max_img_width = config.video_width
|
||||
max_img_height = config.video_height - 400 # Reserve space for subtitle
|
||||
|
||||
# Calculate aspect-fit scaling
|
||||
img_aspect = img.width / img.height
|
||||
target_aspect = max_img_width / max_img_height
|
||||
|
||||
if img_aspect > target_aspect:
|
||||
# Image is wider - fit to width
|
||||
new_width = max_img_width
|
||||
new_height = int(max_img_width / img_aspect)
|
||||
else:
|
||||
# Image is taller - fit to height
|
||||
new_height = max_img_height
|
||||
new_width = int(max_img_height * img_aspect)
|
||||
|
||||
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Paste image at center-top
|
||||
x_offset = (config.video_width - new_width) // 2
|
||||
y_offset = 100 # Leave some space at top
|
||||
canvas.paste(img_resized, (x_offset, y_offset))
|
||||
|
||||
# Add subtitle at bottom
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
self._draw_subtitle(draw, subtitle, config.video_width, config.video_height)
|
||||
|
||||
# Save
|
||||
canvas.save(output_path, quality=95)
|
||||
logger.debug(f"Frame composed: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
def _draw_subtitle(
|
||||
self,
|
||||
draw: ImageDraw.Draw,
|
||||
text: str,
|
||||
width: int,
|
||||
height: int
|
||||
):
|
||||
"""Draw subtitle text at bottom of canvas"""
|
||||
try:
|
||||
if self.font_path:
|
||||
font = ImageFont.truetype(self.font_path, 40)
|
||||
else:
|
||||
font = ImageFont.load_default()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load font: {e}, using default")
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Wrap text if too long
|
||||
max_chars_per_line = 25
|
||||
lines = self._wrap_text(text, max_chars_per_line)
|
||||
|
||||
# Calculate position (bottom area)
|
||||
line_height = 60
|
||||
total_height = len(lines) * line_height
|
||||
y_start = height - total_height - 150 # 150px from bottom
|
||||
|
||||
# Draw each line centered
|
||||
for i, line in enumerate(lines):
|
||||
# Get text bounding box
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
# Center horizontally
|
||||
x = (width - text_width) // 2
|
||||
y = y_start + i * line_height
|
||||
|
||||
# Draw shadow for better readability
|
||||
draw.text((x + 2, y + 2), line, fill='lightgray', font=font)
|
||||
# Draw main text
|
||||
draw.text((x, y), line, fill='black', font=font)
|
||||
|
||||
def _wrap_text(self, text: str, max_chars: int) -> list:
|
||||
"""Wrap text into multiple lines"""
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for char in text:
|
||||
if len(current_line) >= max_chars:
|
||||
lines.append(current_line)
|
||||
current_line = char
|
||||
else:
|
||||
current_line += char
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines
|
||||
|
||||
@@ -193,7 +193,7 @@ class HTMLFrameGenerator:
|
||||
|
||||
async def generate_frame(
|
||||
self,
|
||||
topic: str,
|
||||
title: str,
|
||||
text: str,
|
||||
image: str,
|
||||
ext: Optional[Dict[str, Any]] = None,
|
||||
@@ -204,7 +204,7 @@ class HTMLFrameGenerator:
|
||||
Generate frame from HTML template
|
||||
|
||||
Args:
|
||||
topic: Video topic/theme
|
||||
title: Video title
|
||||
text: Narration text for this frame
|
||||
image: Path to AI-generated image
|
||||
ext: Additional data (content_title, content_author, etc.)
|
||||
@@ -217,7 +217,7 @@ class HTMLFrameGenerator:
|
||||
# Build variable context
|
||||
context = {
|
||||
# Required variables
|
||||
"topic": topic,
|
||||
"title": title,
|
||||
"text": text,
|
||||
"image": image,
|
||||
}
|
||||
|
||||
@@ -162,38 +162,18 @@ class StoryboardProcessorService:
|
||||
storyboard: 'Storyboard',
|
||||
config: StoryboardConfig
|
||||
):
|
||||
"""Step 3: Compose frame with subtitle"""
|
||||
"""Step 3: Compose frame with subtitle using HTML template"""
|
||||
logger.debug(f" 3/4: Composing frame {frame.index}...")
|
||||
|
||||
output_path = get_temp_path(f"frame_{frame.index}_composed.png")
|
||||
|
||||
# Choose frame generator based on template config
|
||||
if config.frame_template:
|
||||
# Use HTML template
|
||||
composed_path = await self._compose_frame_html(frame, storyboard, config, output_path)
|
||||
else:
|
||||
# Use PIL (default)
|
||||
composed_path = await self._compose_frame_pil(frame, config, output_path)
|
||||
# Use HTML template to compose frame
|
||||
composed_path = await self._compose_frame_html(frame, storyboard, config, output_path)
|
||||
|
||||
frame.composed_image_path = composed_path
|
||||
|
||||
logger.debug(f" ✓ Frame composed: {composed_path}")
|
||||
|
||||
async def _compose_frame_pil(
|
||||
self,
|
||||
frame: StoryboardFrame,
|
||||
config: StoryboardConfig,
|
||||
output_path: str
|
||||
) -> str:
|
||||
"""Compose frame using PIL (default)"""
|
||||
composed_path = await self.core.frame_composer.compose_frame(
|
||||
image_path=frame.image_path,
|
||||
subtitle=frame.narration,
|
||||
output_path=output_path,
|
||||
config=config
|
||||
)
|
||||
return composed_path
|
||||
|
||||
async def _compose_frame_html(
|
||||
self,
|
||||
frame: StoryboardFrame,
|
||||
@@ -233,7 +213,7 @@ class StoryboardProcessorService:
|
||||
# Generate frame using HTML
|
||||
generator = HTMLFrameGenerator(str(template_path))
|
||||
composed_path = await generator.generate_frame(
|
||||
topic=storyboard.topic,
|
||||
title=storyboard.title,
|
||||
text=frame.narration,
|
||||
image=frame.image_path,
|
||||
ext=ext,
|
||||
|
||||
138
reelforge/services/title_generator.py
Normal file
138
reelforge/services/title_generator.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Title Generator Service
|
||||
|
||||
Service for generating video titles from content.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
# Title generation constants
|
||||
AUTO_LENGTH_THRESHOLD = 15
|
||||
MAX_TITLE_LENGTH = 15
|
||||
|
||||
|
||||
class TitleGeneratorService:
|
||||
"""
|
||||
Title generation service
|
||||
|
||||
Generates video titles from content using different strategies:
|
||||
- auto: Automatically decide based on content length
|
||||
- direct: Use content directly as title
|
||||
- llm: Always use LLM to generate title
|
||||
"""
|
||||
|
||||
def __init__(self, reelforge_core):
|
||||
"""
|
||||
Initialize title generator service
|
||||
|
||||
Args:
|
||||
reelforge_core: ReelForgeCore instance
|
||||
"""
|
||||
self.core = reelforge_core
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
content: str,
|
||||
strategy: Literal["auto", "direct", "llm"] = "auto",
|
||||
max_length: int = MAX_TITLE_LENGTH
|
||||
) -> str:
|
||||
"""
|
||||
Generate title from content
|
||||
|
||||
Args:
|
||||
content: Source content (topic or script)
|
||||
strategy: Generation strategy
|
||||
- "auto": Auto-decide based on content length (default)
|
||||
* If content <= AUTO_LENGTH_THRESHOLD chars: use directly
|
||||
* If content > AUTO_LENGTH_THRESHOLD chars: use LLM
|
||||
- "direct": Use content directly (truncated to max_length if needed)
|
||||
- "llm": Always use LLM to generate title
|
||||
max_length: Maximum title length (default: MAX_TITLE_LENGTH)
|
||||
|
||||
Returns:
|
||||
Generated title
|
||||
|
||||
Examples:
|
||||
# Auto strategy (default)
|
||||
>>> title = await title_generator("AI技术") # Short, use directly
|
||||
>>> # Returns: "AI技术"
|
||||
|
||||
>>> title = await title_generator("如何在信息爆炸时代保持深度思考") # Long, use LLM
|
||||
>>> # Returns: "信息时代的深度思考" (LLM generated)
|
||||
|
||||
# Direct strategy
|
||||
>>> title = await title_generator("Very long content...", strategy="direct")
|
||||
>>> # Returns: "Very long content..." (truncated to max_length)
|
||||
|
||||
# LLM strategy
|
||||
>>> title = await title_generator("AI", strategy="llm") # Force LLM even for short content
|
||||
>>> # Returns: "人工智能技术" (LLM generated)
|
||||
"""
|
||||
if strategy == "direct":
|
||||
return self._use_directly(content, max_length)
|
||||
elif strategy == "llm":
|
||||
return await self._generate_by_llm(content, max_length)
|
||||
else: # auto
|
||||
if len(content.strip()) <= AUTO_LENGTH_THRESHOLD:
|
||||
return content.strip()
|
||||
return await self._generate_by_llm(content, max_length)
|
||||
|
||||
def _use_directly(self, content: str, max_length: int) -> str:
|
||||
"""
|
||||
Use content directly as title (with truncation if needed)
|
||||
|
||||
Args:
|
||||
content: Source content
|
||||
max_length: Maximum title length
|
||||
|
||||
Returns:
|
||||
Truncated or original content
|
||||
"""
|
||||
content = content.strip()
|
||||
if len(content) <= max_length:
|
||||
return content
|
||||
return content[:max_length]
|
||||
|
||||
async def _generate_by_llm(self, content: str, max_length: int) -> str:
|
||||
"""
|
||||
Generate title using LLM
|
||||
|
||||
Args:
|
||||
content: Source content (topic or script)
|
||||
max_length: Maximum title length
|
||||
|
||||
Returns:
|
||||
LLM-generated title
|
||||
"""
|
||||
from reelforge.prompts import build_title_generation_prompt
|
||||
|
||||
# Build prompt using template
|
||||
prompt = build_title_generation_prompt(content, max_length=500)
|
||||
|
||||
# Call LLM to generate title
|
||||
response = await self.core.llm(
|
||||
prompt=prompt,
|
||||
temperature=0.7,
|
||||
max_tokens=50
|
||||
)
|
||||
|
||||
# Clean up response
|
||||
title = response.strip()
|
||||
|
||||
# Remove quotes if present
|
||||
if title.startswith('"') and title.endswith('"'):
|
||||
title = title[1:-1]
|
||||
if title.startswith("'") and title.endswith("'"):
|
||||
title = title[1:-1]
|
||||
|
||||
# Limit to max_length (safety)
|
||||
if len(title) > max_length:
|
||||
title = title[:max_length]
|
||||
|
||||
logger.debug(f"Generated title: '{title}' (length: {len(title)})")
|
||||
|
||||
return title
|
||||
|
||||
@@ -127,7 +127,7 @@ class VideoGeneratorService:
|
||||
video_height: Final video height (default 1920)
|
||||
video_fps: Video frame rate (default 30)
|
||||
|
||||
frame_template: HTML template filename or path (None = use PIL)
|
||||
frame_template: HTML template filename or path (None = use default template)
|
||||
e.g., "default.html", "modern.html", "neon.html", or custom path
|
||||
|
||||
prompt_prefix: Image prompt prefix (overrides config.yaml if provided)
|
||||
@@ -181,15 +181,15 @@ class VideoGeneratorService:
|
||||
final_title = title
|
||||
logger.info(f" Title: '{title}' (user-specified)")
|
||||
else:
|
||||
# Auto-generate title based on mode
|
||||
# Auto-generate title using title_generator service
|
||||
self._report_progress(progress_callback, "generating_title", 0.01)
|
||||
if mode == "generate":
|
||||
# Use text as title (it's a topic/theme)
|
||||
final_title = text[:20] if len(text) > 20 else text
|
||||
logger.info(f" Title: '{final_title}' (from text)")
|
||||
# Auto strategy: decide based on content length
|
||||
final_title = await self.core.title_generator(text, strategy="auto")
|
||||
logger.info(f" Title: '{final_title}' (auto-generated)")
|
||||
else: # fixed
|
||||
# Generate title from script using LLM
|
||||
self._report_progress(progress_callback, "generating_title", 0.01)
|
||||
final_title = await self._generate_title_from_content(text)
|
||||
# Force LLM strategy: always use LLM for script
|
||||
final_title = await self.core.title_generator(text, strategy="llm")
|
||||
logger.info(f" Title: '{final_title}' (LLM-generated)")
|
||||
|
||||
# Auto-generate output path if not provided
|
||||
@@ -216,12 +216,12 @@ class VideoGeneratorService:
|
||||
image_width=image_width,
|
||||
image_height=image_height,
|
||||
image_workflow=image_workflow,
|
||||
frame_template=frame_template
|
||||
frame_template=frame_template or "default.html"
|
||||
)
|
||||
|
||||
# Create storyboard
|
||||
storyboard = Storyboard(
|
||||
topic=final_title, # Use final_title as video title
|
||||
title=final_title, # Use final_title as video title
|
||||
config=config,
|
||||
content_metadata=content_metadata,
|
||||
created_at=datetime.now()
|
||||
@@ -564,41 +564,4 @@ class VideoGeneratorService:
|
||||
logger.info(f" Too long (> {max_len}): {too_long}/{len(result)} ({too_long*100//len(result)}%)")
|
||||
|
||||
return result
|
||||
|
||||
async def _generate_title_from_content(self, content: str) -> str:
|
||||
"""
|
||||
Generate a short, attractive title from user content using LLM
|
||||
|
||||
Args:
|
||||
content: User-provided content
|
||||
|
||||
Returns:
|
||||
Generated title (10 characters or less)
|
||||
"""
|
||||
from reelforge.prompts import build_title_generation_prompt
|
||||
|
||||
# Build prompt using template
|
||||
prompt = build_title_generation_prompt(content, max_length=500)
|
||||
|
||||
# Call LLM to generate title
|
||||
response = await self.core.llm(
|
||||
prompt=prompt,
|
||||
temperature=0.7,
|
||||
max_tokens=50
|
||||
)
|
||||
|
||||
# Clean up response
|
||||
title = response.strip()
|
||||
|
||||
# Remove quotes if present
|
||||
if title.startswith('"') and title.endswith('"'):
|
||||
title = title[1:-1]
|
||||
if title.startswith("'") and title.endswith("'"):
|
||||
title = title[1:-1]
|
||||
|
||||
# Limit to 20 chars max (safety)
|
||||
if len(title) > 20:
|
||||
title = title[:20]
|
||||
|
||||
return title
|
||||
|
||||
|
||||
@@ -115,14 +115,14 @@
|
||||
.square-2 { top: 45%; right: 80px; }
|
||||
.square-3 { bottom: 20%; left: 100px; transform: rotate(45deg); }
|
||||
|
||||
/* Topic section */
|
||||
.topic-wrapper {
|
||||
/* Video title section */
|
||||
.video-title-wrapper {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.topic-ornament-top {
|
||||
.video-title-ornament-top {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 50%;
|
||||
@@ -145,7 +145,7 @@
|
||||
background: rgba(149, 165, 166, 0.65);
|
||||
}
|
||||
|
||||
.topic {
|
||||
.video-title {
|
||||
font-size: 68px;
|
||||
font-weight: 600;
|
||||
color: #1a252f;
|
||||
@@ -154,7 +154,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topic::after {
|
||||
.video-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
@@ -460,15 +460,15 @@
|
||||
</div>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="topic-wrapper">
|
||||
<div class="video-title-wrapper">
|
||||
<!-- Top ornament -->
|
||||
<div class="topic-ornament-top">
|
||||
<div class="video-title-ornament-top">
|
||||
<div class="ornament-line"></div>
|
||||
<div class="ornament-dot"></div>
|
||||
<div class="ornament-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="topic">{{topic}}</div>
|
||||
<div class="video-title">{{title}}</div>
|
||||
</div>
|
||||
|
||||
<div class="image-wrapper">
|
||||
|
||||
@@ -149,12 +149,12 @@
|
||||
}
|
||||
|
||||
/* Header with icon decoration */
|
||||
.topic-wrapper {
|
||||
.video-title-wrapper {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.topic-decoration {
|
||||
.video-title-decoration {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
left: 50%;
|
||||
@@ -163,17 +163,17 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topic-dot {
|
||||
.video-title-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.topic-dot:nth-child(1) { background: #FF6B9D; }
|
||||
.topic-dot:nth-child(2) { background: #4ECDC4; }
|
||||
.topic-dot:nth-child(3) { background: #FFE66D; }
|
||||
.video-title-dot:nth-child(1) { background: #FF6B9D; }
|
||||
.video-title-dot:nth-child(2) { background: #4ECDC4; }
|
||||
.video-title-dot:nth-child(3) { background: #FFE66D; }
|
||||
|
||||
.topic {
|
||||
.video-title {
|
||||
font-size: 72px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
@@ -184,8 +184,8 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.topic::before,
|
||||
.topic::after {
|
||||
.video-title::before,
|
||||
.video-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 70px;
|
||||
@@ -194,11 +194,11 @@
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.topic::before {
|
||||
.video-title::before {
|
||||
left: -90px;
|
||||
}
|
||||
|
||||
.topic::after {
|
||||
.video-title::after {
|
||||
right: -90px;
|
||||
}
|
||||
|
||||
@@ -492,11 +492,11 @@
|
||||
</div>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="topic-wrapper">
|
||||
<div class="topic-decoration">
|
||||
<div class="topic-dot"></div>
|
||||
<div class="topic-dot"></div>
|
||||
<div class="topic-dot"></div>
|
||||
<div class="video-title-wrapper">
|
||||
<div class="video-title-decoration">
|
||||
<div class="video-title-dot"></div>
|
||||
<div class="video-title-dot"></div>
|
||||
<div class="video-title-dot"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bookmark decoration -->
|
||||
@@ -504,7 +504,7 @@
|
||||
<path d="M2 0h20v32l-10-6-10 6V0z"/>
|
||||
</svg>
|
||||
|
||||
<div class="topic">{{topic}}</div>
|
||||
<div class="video-title">{{title}}</div>
|
||||
</div>
|
||||
|
||||
<div class="image-wrapper">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{topic}}</title>
|
||||
<title>{{title}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f1a;
|
||||
@@ -865,7 +865,7 @@
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header" role="banner">
|
||||
<h1 class="title">{{topic}}</h1>
|
||||
<h1 class="title">{{title}}</h1>
|
||||
<div class="title-meta" role="list">
|
||||
<div class="chip" role="listitem"><span class="dot"></span>读书短视频</div>
|
||||
<div class="chip" role="listitem"><span class="dot pink"></span>思维提升</div>
|
||||
@@ -874,7 +874,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Media -->
|
||||
<section class="media" role="img" aria-label="Illustration for the topic">
|
||||
<section class="media" role="img" aria-label="Illustration for the video">
|
||||
<!-- Corner indicators -->
|
||||
<div class="corner-indicator tl"></div>
|
||||
<div class="corner-indicator tr"></div>
|
||||
@@ -893,7 +893,7 @@
|
||||
<div class="badge-dot pink"></div>
|
||||
</div>
|
||||
|
||||
<img src="{{image}}" alt="图像:{{topic}}">
|
||||
<img src="{{image}}" alt="图像:{{title}}">
|
||||
</section>
|
||||
|
||||
<!-- Caption -->
|
||||
|
||||
Reference in New Issue
Block a user