Merge branch 'dev'

This commit is contained in:
songtianlun 2025-02-13 17:22:11 +08:00
commit ee7ff023df
12 changed files with 229 additions and 40 deletions

View File

@ -4,7 +4,6 @@ class WeatherArt < ApplicationRecord
belongs_to :city
has_one_attached :image
has_one_attached :image_with_watermark
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :weather_art_id
has_many :events, class_name: "Ahoy::Event", foreign_key: :weather_art_id
@ -59,4 +58,145 @@ class WeatherArt < ApplicationRecord
def image_url
image.attached? ? image.blob : nil
end
def webp_image
return nil unless image.attached?
image.variant(
format: "webp",
saver: {
quality: 100,
strip: true, # 移除元数据以减小文件大小
interlace: "plane" # 渐进式加载
}
)
end
# 添加图片变体处理
PREVIEW_DIMENSIONS = {
big: [ 1792, 1024 ],
medium: [ 896, 512 ],
small: [ 448, 256 ]
}.freeze
def preview_image(size = :medium)
return nil unless image.attached?
width, height = PREVIEW_DIMENSIONS[size] || PREVIEW_DIMENSIONS[:medium]
image.variant(
resize_to_limit: [ width, height ],
format: "webp",
saver: {
quality: 75,
strip: true, # 移除元数据以减小文件大小
interlace: "plane" # 渐进式加载
}
)
end
def watermarked_image
return nil unless image.attached?
overlay_text = create_overlay_text
image.variant(
composite: [ {
input: overlay_text,
gravity: "southeast"
} ]
)
end
private
def create_overlay_text
{
create: {
width: 400,
height: 100,
background: [ 0, 0, 0, 0.5 ] # 半透明黑色背景
},
"svg-overlay": %(
<svg width="400" height="100">
<text x="20" y="40"
style="fill: white; font-family: Arial; font-size: 20px;">
#{city.name} - #{weather_date.strftime('%Y-%m-%d')}
</text>
<text x="20" y="70"
style="fill: white; font-family: Arial; font-size: 20px;">
© todayaiweather.com
</text>
</svg>
)
}
end
def create_text_layer(font_size)
text = [
weather_date.strftime("%Y-%m-%d"),
"#{temperature}°C, #{description}",
"#{city.name}, #{city.country.name}, #{city.country.region.name}",
"© todayaiweather.com"
].join("\n")
{
create: {
width: 600,
height: 200,
background: [ 0, 0, 0, 0 ]
},
"svg-overlay": %(
<svg width="600" height="200">
<style>
.text {
font-family: Arial, sans-serif;
font-size: #{font_size}px;
}
.shadow {
fill: white;
stroke: black;
stroke-width: 2px;
paint-order: stroke fill;
}
</style>
<text x="20" y="#{font_size + 10}" class="text shadow">#{weather_date.strftime('%Y-%m-%d')}</text>
<text x="20" y="#{font_size * 2 + 20}" class="text shadow">#{temperature}°C, #{description}</text>
<text x="20" y="#{font_size * 3 + 30}" class="text shadow">#{city.name}, #{city.country.name}</text>
<text x="20" y="#{font_size * 4 + 40}" class="text shadow">© todayaiweather.com</text>
</svg>
)
}
end
def watermark_command(font_size:, stroke_width:, spacing:)
date_str = weather_date.strftime("%Y-%m-%d")
weather_info = "#{temperature}°C, #{description}"
location_info = "#{city.name}, #{city.country.name}, #{city.country.region.name}"
copyright = "© todayaiweather.com"
"gravity southeast " \
"fill white " \
"font Arial " \
"pointsize #{font_size} " \
"stroke black " \
"strokewidth #{stroke_width} " \
"text 30,#{spacing * 12} '#{copyright}' " \
"text 30,#{spacing * 8} '#{location_info}' " \
"text 30,#{spacing * 4} '#{weather_info}' " \
"text 30,#{spacing} '#{date_str}'"
end
def watermark_text
date_str = weather_date.strftime("%Y-%m-%d")
weather_info = "#{temperature}°C, #{description}"
location_info = "#{city.name}, #{city.country.name}, #{city.country.region.name}"
copyright = "© todayaiweather.com"
[
"text 30,120 '#{copyright}'",
"text 30,80 '#{location_info}'",
"text 30,40 '#{weather_info}'",
"text 30,0 '#{date_str}'"
].join(" ")
end
end

View File

@ -3,6 +3,7 @@ class AiService
@client = OpenAI::Client.new(
access_token: Rails.application.credentials.openai.token,
uri_base: Rails.application.credentials.openai.uri,
log_errors: Rails.env.development?, # 只在开发环境下启用
request_timeout: 240
)
end
@ -47,8 +48,8 @@ class AiService
def ask_ai(system_message, user_message)
response = @client.chat(
parameters: {
model: "gpt-4",
message:
model: "gpt-4o",
messages:
[ {
role: "System",
content: system_message

View File

@ -31,8 +31,8 @@ class WeatherService
pressure: data["pressure"].to_f,
visibility: data["vis"].to_f,
cloud: data["cloud"].to_f,
description: data["text"],
time: response["updateTime"]
description: data["text"]
# time: response["updateTime"]
}
end
end

View File

@ -6,7 +6,7 @@
<!-- 背景图像 -->
<% if featured_art&.image&.attached? %>
<div class="absolute inset-0 h-[40vh] overflow-hidden">
<%= image_tag featured_art.image,
<%= image_tag featured_art.webp_image.processed,
class: "w-full h-full object-cover" %>
<div class="absolute inset-0 bg-gradient-to-b from-base-100/30 via-base-100/60 to-base-100"></div>
</div>
@ -107,7 +107,7 @@
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 group overflow-hidden">
<figure class="relative aspect-square overflow-hidden">
<% if art.image.attached? %>
<%= image_tag art.image,
<%= image_tag art.preview_image.processed,
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %>
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>

View File

@ -5,7 +5,7 @@
<div class="relative">
<!-- 图片 -->
<figure class="aspect-[16/9] overflow-hidden">
<%= image_tag city.latest_weather_art.image,
<%= image_tag city.latest_weather_art.preview_image.processed,
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %>
</figure>

View File

@ -6,7 +6,7 @@
<!-- 背景图像和渐变 -->
<% if featured_art&.image&.attached? %>
<div class="absolute inset-0 h-[60vh] overflow-hidden">
<%= image_tag featured_art.image,
<%= image_tag featured_art.webp_image.processed,
class: "w-full h-full object-cover object-center" %>
<div class="absolute inset-0 bg-gradient-to-b from-base-100/40 via-base-100/80 to-base-100"></div>
</div>

View File

@ -2,7 +2,7 @@
<!-- 背景效果 -->
<% if @city.latest_weather_art&.image&.attached? %>
<div class="fixed inset-0 -z-10">
<%= image_tag @city.latest_weather_art.image,
<%= image_tag @city.latest_weather_art.webp_image.processed,
class: "absolute w-full h-full object-cover scale-110 filter blur-2xl opacity-25" %>
<div class="absolute inset-0 bg-gradient-to-b from-base-200/90 to-base-200/70 backdrop-blur-md"></div>
</div>
@ -103,7 +103,7 @@
<div class="card bg-base-100/80 backdrop-blur-sm shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
<figure class="relative aspect-video overflow-hidden">
<% if art.image.attached? %>
<%= image_tag art.image,
<%= image_tag art.preview_image.processed,
class: "w-full h-full object-cover transform hover:scale-105 transition-transform duration-500" %>
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/70 to-transparent">
<div class="flex items-center justify-between text-white">

View File

@ -27,15 +27,15 @@
<figure class="relative lg:h-[30rem] h-auto overflow-hidden rounded-lg"> <!-- 添加圆角 -->
<div class="gallery" data-controller="photo-swipe-lightbox">
<div data-photo-swipe-lightbox-target="gallery" class="h-full">
<% blob = @weather_art.image_blob %>
<%= link_to rails_blob_path(blob),
<% watermarked = @weather_art.webp_image.processed %>
<%= link_to rails_blob_path(watermarked),
data: {
pswp_src: rails_blob_url(blob),
pswp_src: rails_blob_url(watermarked),
pswp_caption: 'Weather Art',
pswp_width: 1792,
pswp_height: 1024
} do %>
<%= image_tag @weather_art.image, class: "object-cover w-full h-full transition-transform transform hover:scale-105 ease-in-out" %>
<%= image_tag @weather_art.preview_image(:big).processed, class: "object-cover w-full h-full transition-transform transform hover:scale-105 ease-in-out" %>
<%#= image_tag @weather_art.watermarked_variant.processed , class: "object-cover w-full h-full transition-transform transform hover:scale-105 ease-in-out" %>
<% end %>
</div>

View File

@ -1,38 +1,85 @@
class BatchGenerateWeatherArtsWorker
include Sidekiq::Worker
GENERATION_INTERVAL = 36.hours
MAX_DURATION = 50.minutes
SLEEP_DURATION = 120.seconds
BATCH_SIZE = 20
module ProcessType
DO_NOTHING = 0
GET_WEATHER_WITH_GENERATE_AI_ART = 1
GET_WEATHER_ONLY = 2
end
DAILY_GENERATION_LIMIT = 50 # 每日生成图片上限
def perform(*args)
start_time = Time.current
cities_to_process = get_eligible_cities.shuffle.take(BATCH_SIZE)
cities_to_process.each do |city|
remaining_slots = calculate_remaining_slots
return if remaining_slots <= 0
# 获取符合条件的城市并处理
cities_to_process = select_cities(remaining_slots)&.shuffle
print_cities_list(cities_to_process, start_time)
process_cities(cities_to_process, start_time)
end
private
def calculate_remaining_slots
today_generations = WeatherArt
.where("DATE(created_at) = ?", Date.today)
.where.not(image_attachment: nil)
.count
[ DAILY_GENERATION_LIMIT - today_generations, 0 ].max
end
def print_cities_list(cities, start_time)
Rails.logger.info "Generate city task start at: #{start_time}"
Rails.logger.info "Generate city list: "
Rails.logger.info "======================================"
Rails.logger.info "ID\tRegion\tCountry\tState\tName"
cities.each do |city|
Rails.logger.info "#{city.id}\t#{city&.country&.region&.name}\t#{city&.country&.name}\t#{city&.state&.name}\t#{city&.name}"
end
end
def select_cities(limit)
cutoff_time = Time.current - GENERATION_INTERVAL
# 基础查询:排除最近生成过的城市
base_query = City
.joins("LEFT JOIN (
SELECT city_id, MAX(created_at) as last_generation_time
FROM weather_arts
GROUP BY city_id
) latest_arts ON cities.id = latest_arts.city_id")
.where("latest_arts.last_generation_time IS NULL OR latest_arts.last_generation_time < ?", cutoff_time)
# 优先选择活跃城市
active_cities = base_query
.where(active: true)
.order(:priority)
.limit(limit)
.to_a
remaining_slots = limit - active_cities.size
if remaining_slots > 0
# 如果还有剩余名额,从其他城市中随机选择
other_cities = base_query
.where.not(id: active_cities.map(&:id))
.order("RANDOM()")
.limit(remaining_slots)
.to_a
active_cities + other_cities
else
active_cities
end
end
def process_cities(cities, start_time)
cities.each do |city|
break if Time.current - start_time > MAX_DURATION
Rails.logger.info "Generating weather art for #{city.name}"
GenerateWeatherArtWorker.perform_async(city.id)
sleep SLEEP_DURATION
end
end
private
def get_eligible_cities
cutoff_time = Time.current - GENERATION_INTERVAL
City.active
.joins("LEFT JOIN (
SELECT city_id, MAX(created_at) as last_generation_time
FROM weather_arts
GROUP BY city_id
) latest_arts ON cities.id = latest_arts.city_id")
.where("latest_arts.last_generation_time IS NULL OR latest_arts.last_generation_time < ?", cutoff_time)
.order(:priority)
end
end

View File

@ -14,6 +14,7 @@ class GenerateWeatherArtWorker
return unless image_url
create_weather_art(weather_data, prompt, image_url)
Rails.logger.info "Successful Generate Weather Art In #{city.name}"
rescue StandardError => e
Rails.logger.error "Error generating weather art for city #{city_id}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")

View File

@ -1 +1 @@
WIRCFJoA1YUpdSuljSkBqEJvBpftvTRGA73T9Oy9vXNnOIHcnZDHtlhh2VuF4Xtq41d0aPJ2qPI4kUeGzM34I6FITsSY6i1y5ZEW4vUa8F/BRTO0/viXBPiSAS+BkWVEWYrFkNWJAIitVLG0hq8vCBEEs/KctxY8w9h0LwgyRV2udPzn6A7wyoJhR/V5nRamU6USFiHh6cpTlFQdSIZ0sC6v21IIS+G/ZPJXNNTw2+gVaDfL1yuX1nrLsxeMsb30iugksDj/gCVi42kF/hPVOGIZCM1ftM5j4Q+BcTDyEVqIMeXRdtegLKWM5sI0yB070m7gMRK1Oewa3z+NuPLGp1Stuuo977LGuATmP3/GnAMEZe8tgMfeKYeQv2+TerpLmO07KayOzN3j0qSs/OqrcSUP+SByxRBmpmeXNEQ6q+f1ZDreo/Q1Cm+aZe1UWXYpcgd6MVGHjYUna4SgTQqUKHKdzvf7Yx8fOjrzHRqZ6Y8LWq53Vzr8oNJ9IoNAh9TSJMF4tR3M+OQ7+SARgwQLoVsehgK1Z658GMvhVoeLrPTwvdID54+TSFMepMnownDJPZGKZoZK1+NlUyzz3rJ2iH9AxZnvwPMCcmlHH/Fh/FANmtQxd3DIPpjHHyZjxFLsR9tNuIapXPK8SiRhWbtYo+4i0/dgUvD8SfECSOklfx2tVIhvxTjg4AM+WtI9gieDISq+AZe9fjqyv8vLOJuDX8Nk1k69DKIqB2u+OFJW2/PPYFz2Ry283ep+VRKwS2qlsFEkTRkzPJ5y9dVrCXIYl0R7vgC+GDrUkK1IffZmTaJ+rqmzzwLF1OwPdtN/QuJm0aZZQmN9pQgUjt1PEgthQVW9dt/ElDZKgVjIcds3F1kX0+ZWJQRvBldilC9W5lRSea/9x253dw3wj1ERF6sZsIJCwcxTJigTM4rEfcjrMnEIfiW1HWc1LTJ5DUIh5GISBv7NDF52voP2TBamAq2Zg2vVg2v+bU9ydkkIVOm4oLYZWDQyDBeQC4KJscFCSo1ZxvRuqIRtnI3h2KoXJeK63cxGcVxbHcyYO5xTPXmPkk18pFPYoXe46an6xTEzxYXQMfWuIUvM9oOjgZXFvdgPwDWzL/pSZb6qW5MiDqvlt38ddivUtsZLMMtpgZjXOAyKQyIQNSOHyrrYoj6r8PdFiCZMWjn1B0HmXgIl47jrb9o1nrif9rb5DSUY2Pvl0+TeRnUB8r7Wa+t+UdHjrq6PEaXVOQbej78Rzo5xmZ5XknlbKv02Vjpcm8EIkc00nKvMo29f0mr1Wv48CL+tuDxiW3v2yX8NcTnMEoQkTTaw5wJmSb3dmPdVZwjcCdbqs1CbRpc/ngX7nJP9kF2P57BWqqYq6V4fWk3vEgLpTUsTCbJvTlCx7McLKy2/smtz2HQl6Vs5jofpSIXgNC35TBkPP22XLFiBTah0E2IUeF2TyuLphswQsMDzKzADMxkKpzEVNLCzzKfygDfsfsJqZjKBGHJz3RHDbGoqmgP/4u7FEBV9gUXFt5i5k71HxSnafbpcFkLk3AdfNLso2qUNZSj/8EzY33WFjQIEAFJeTqB8WML/LcVvNqQqvjUyUMgvA+rZq+BwS8svqJEvcRFmZhjeqQbsYs+C+jOlwjrohoak/CgfiHofuXWvnNyi0+PT6bPO5CsjbpJLwFxbPi19tWmnMR7ZOqIE3vN4bf8Rjg4T5zhKKbgIU7wEP+i0u9p0zPFEa6EZMxp8/5q50EjXdlY/cHa/Sp05x2h8jhi1Trxgg8yFOdu2P+dStAwW3Sv4x2o=--aZKHys4Q3dCV2P+L--yEOdlrHAYleGIYmdKAB4FQ==
mNiwLK27M1vAWefYLzYIiQO0jBJqqZ1Yo0T1YOcldA3YrQo+AVm1vImv7Nbc96N3OK4f782Q3lNp4mTPl0zUFjiWy2QN/VdBMN9B2uxcUXJIASCJMwDlrc+e2evD6dqUb/hTj0q2Hi/uMVuRBI1O/bDvxxBQma/4MyPgwm6uwnP2g1yLfiBkgveU72yc4tHHmzXwEoxtJa3VkVKus+QWwCyWe/ZKvoDF2RplQm7RYVXgsbNhSdTVfoGvH9ExKRZvKjzrXutLtXe4YpHiiLSisCQBLYqF/XptG86U/uFldtCcbcB6XX2psyrQxHmhdxHnucMSx35rvh4XrjPMNKDpCiYuvJepjaJzgPDRkxjf4txIUHnIs3p6lVto+b92vq2OGKAq/nUwTpAB46WVz4CNCI0Z+QHkbSZ05a6cHAkzBY1Z4uiwu7gM6AFN/jYOdDo6GzxrP6h/Msp3wmOgKiM8Ts1kysXNdJ6E8X8GHPNKfHLnyVnoVrJbY1GGv6oXu4WkIxAK5v6Mi10J0Sd6QQJuZYv2SKNCjXUtxrm4NFWd9DY3BMvF3TSDRU17riruPyJHCYKSqCQHobblC7Sr1lwcN3+CfOJqGXR66D0OH6DNiHwobxsyVSHzSePl5bVi9a44V+GtzDJ0fZQYOpVnzWOsMj++K89dz2qtruB7BSOQe9YAXGk+dk//XqUebz4fqYUiW79aj4M1DyFquapbcn+WpzmXFGuVORM2ZuQjUr0Glev8JMq95R4JIZkMDwm5/q7YUbqEb25yMdhs5c48vmCIy3itliieWN9kYZO6pqIsP73KXms2Xi5nJ+FcaFQ9T24zs/S9JcA1a2KUl6n8kYBcVYBdNBvE+9ab+eJ3RUp3YMOErmMHAtTsUXQCZLmXpRheaaOkupS/XB+M+jYgYLI6+sv86ISZsYz472iT9UMCn5F/X8lNIwDClPoJP2kafLDq6hMlo76Oa/aVj+miy7tJIWFgVvMJ3VwPf526MMZgNShF6tZ+Z7PA0pr+9WuoZSyQ0Ai9lql/Tk+r2ZUPT6Z28qjGr9ln2jbb509ETwhSxnHjINskNyLmfFWUcELy+BEmOSn9GQgay0PoFkUrYpidpXD9Tw2IpEpklyIGhr4HFHv4bQ5uBWjPrIGm3R8fjATA0xB+Wg9SDPSHd+QclAzcL6Po8UlLIZfROjReg7ZJn5+8x0rf7UidNXTfy+JM0GmUR9aUh110RKE0u1WjdhhzdVDiQH2m58CZf3TsP3z4o8SLWfXAftqU8p3sBXPOV+L5RyWrUMzvWmDFzL0RmP854fOILsq76QoI4PULPHD7AIGaCHxuM+qmNQvYhFBMDCaHEeLP4ARX2NmUxMCT40T7bWEFbrAJ14Kd8L/EAjYVsGrBTWGZSvB3aZcZnP3cy4kC9XWvfAksstm9dPSqbxH7d8POb5KJBhSnfm5UWczc24s/6TC9UON921Shz3e9tk7bbBSBNIA/pHWmoZ8vFS3Cegihnt9TnALlWDBrGm7arYr3oQJHqkQ4KPEh36EwZDw/+YDaXLhN2yR8hL7nGPsewcwKdYEU4bGjTpI6xtjs0hBgwuMwTNWUsEm+bijt2feTxzJgUyT9ywW7aSsDfZi88J06LMJfpeAUIjhTjB9Coohk33lrpmsrD5Hb3RaObELlrpG6RkCHlmTxMTjTonS9ni+7dXUYJFYYvtLuyFmhSxEEk7bCrCMVkOWN5tTs0y0J22bxT/FrGLn/GKKlK/FVAeoGN8ZvEOCmIizEQ8k6wgAbuLDaTxV1irLLOeaY3JtjwVxBQHfNuCgxKXxd0zcUqv7D/sgMC0zafcqshG8VaXfGvFexWVEnZjPiZ6aHzeNMhdTPsf9D0p8R3PFgcvC2rSLYNbj2MXnvGqkrtQCre0CiNVxflVvOHbJjugdp5LqAjf6bssCsrZKifaRTPeg=--B6BEVJN27qT5bDUL--B4LzjOJ7SlzEKibq4VSVmg==

View File

@ -1,7 +1,7 @@
batch_generate_weather:
cron: '0 8,18 * * *'
class: BatchGenerateWeatherArtsWorker
description: "Generate weather arts every 2 hours"
description: "Batch Generate weather arts"
enabled: true
refresh_sitemap: