├── golden_hour ├── __init__.py ├── configuration.py ├── timelapse │ ├── __init__.py │ ├── ffmpeg.py │ ├── osx.py │ └── pi.py ├── location.py ├── sunset.py ├── tweet.py ├── main.py └── weather.py ├── requirements.txt ├── example_config.yaml ├── setup.py ├── .gitignore ├── authenticate_twitter.py └── README.md /golden_hour/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | astral==1.3.4 2 | darkskylib==0.3.6 3 | python-twitter==3.4.1 4 | pytz==2016.10 5 | PyYAML==3.12 6 | six==1.10.0 7 | -------------------------------------------------------------------------------- /golden_hour/configuration.py: -------------------------------------------------------------------------------- 1 | import schema 2 | import yaml 3 | 4 | from golden_hour import tweet, location 5 | 6 | 7 | GOLDENHOUR_CONFIGURATION_SCHEMA = schema.Schema({ 8 | 'location': location.LOCATION_CONFIG_SCHEMA, 9 | schema.Optional('twitter'): tweet.TWITTER_CONFIG_SCHEMA, 10 | schema.Optional('darksky_key'): str, 11 | }) 12 | 13 | 14 | def load_configuration(config_file_path): 15 | with open(config_file_path) as config_file: 16 | config = yaml.load(config_file.read()) 17 | 18 | return GOLDENHOUR_CONFIGURATION_SCHEMA.validate(config) -------------------------------------------------------------------------------- /example_config.yaml: -------------------------------------------------------------------------------- 1 | ## If your location is recognized by Astral, just provide the location name: 2 | ## For a list of cities recognized, see: https://astral.readthedocs.io/en/latest/#cities 3 | location: Seattle 4 | ## Alternatively, configure it like so: (use one or the other format but not both) 5 | #location: 6 | # latitude: 48.769768 7 | # longitude: -122.485886 8 | # timezone: 'US/Pacific' 9 | # elevation: 30 10 | ## To get Twitter API credentials, set up your Twitter app at https://apps.twitter.com/ 11 | #twitter: 12 | # consumer_key: YOUR CONSUMER KEY HERE 13 | # consumer_secret: YOUR CONSUMER SECRET HERE 14 | # access_token_key: YOUR ACCESS TOKEN KEY HERE 15 | # access_token_secret: YOUR ACCESS TOKEN SECRET HERE 16 | ## To include the weather report (optional), set up a dark sky API key at https://darksky.net/dev 17 | #darksky_key: YOUR DARKSKY KEY HERE -------------------------------------------------------------------------------- /golden_hour/timelapse/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import platform 3 | import shutil 4 | import tempfile 5 | 6 | from .ffmpeg import compile_video 7 | if platform.system() == 'Darwin': 8 | from .osx import capture 9 | else: 10 | from .pi import capture 11 | 12 | 13 | logger = logging.getLogger() 14 | 15 | 16 | def create_timelapse(duration, interval, filename, persistent_photos_dir=None): 17 | logger.info('recording timelapse (duration: {}, interval: {}, filename: {})'.format( 18 | duration, interval, filename)) 19 | 20 | if persistent_photos_dir is None: 21 | photos_dir = tempfile.mkdtemp(suffix='_golden-hour') 22 | else: 23 | photos_dir = persistent_photos_dir 24 | 25 | capture(photos_dir, duration, interval) 26 | compile_video(photos_dir, filename) 27 | 28 | if persistent_photos_dir is None: 29 | shutil.rmtree(photos_dir) 30 | -------------------------------------------------------------------------------- /golden_hour/timelapse/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | 4 | 5 | logger = logging.getLogger() 6 | 7 | 8 | def compile_video(photos_dir, output_filename, photos_per_second=30): 9 | logger.info('compiling timelapse (photos per second: {photos_per_second})'.format( 10 | photos_per_second=photos_per_second, 11 | )) 12 | # TODO ensure output_filename ends with .mp4 13 | photos_pattern = '{}/image%05d.png'.format(photos_dir) 14 | try: 15 | subprocess.check_call([ 16 | 'ffmpeg', 17 | '-loglevel', 'warning', 18 | '-framerate', str(photos_per_second), 19 | '-i', photos_pattern, 20 | '-c:v', 'libx264', 21 | '-r', '30', 22 | '-pix_fmt', 'yuv420p', 23 | output_filename, 24 | ]) 25 | except subprocess.CalledProcessError as error: 26 | logger.error('Error encountered while generating video using ffmpeg', exc_info=True) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from os import path 3 | from setuptools import setup, find_packages 4 | 5 | this_directory = path.abspath(path.dirname(__file__)) 6 | with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: 7 | long_description = f.read() 8 | 9 | setup( 10 | name="golden_hour", 11 | version="1.0.0", 12 | description="Record a sunset timelapse and post it to Twitter with a weather report", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/alanhussey/golden-hour", 16 | packages=find_packages(), 17 | entry_points={ 18 | "console_scripts": [ 19 | "golden-hour=golden_hour.main:main", 20 | "golden-hour-tweet=golden_hour.tweet:main" 21 | ] 22 | }, 23 | install_requires=[ 24 | "astral==1.3.4", 25 | "darkskylib==0.3.6", 26 | "python-twitter==3.4.1", 27 | "pytz==2016.10", 28 | "PyYAML==3.12", 29 | "schema", 30 | "six==1.10.0", 31 | ], 32 | include_package_data=True, 33 | ) 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | twitter_secrets.yaml 2 | output 3 | 4 | # Github's Python gitignore: 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # IPython Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | -------------------------------------------------------------------------------- /golden_hour/location.py: -------------------------------------------------------------------------------- 1 | from astral import Astral, Location 2 | import schema 3 | 4 | # Lowest reasonable elevation is Death Valley, about 100m below sea level. 5 | LOWEST_ALLOWED_ELEVATION = -100 6 | 7 | LOCATION_CONFIG_SCHEMA = schema.Schema( 8 | # Location configuration can either be: 9 | schema.Or( 10 | # A string that Astral recognizes (see https://astral.readthedocs.io/en/latest/#cities) 11 | str, 12 | # or the lat/lon/timezone/elevation parts we need to create an Astral object 13 | { 14 | 'latitude': schema.And(schema.Use(float), lambda lat: -90 < lat < 90), 15 | 'longitude': schema.And(schema.Use(float), lambda lon: -180 < lon < 180), 16 | 'timezone': str, 17 | 'elevation': schema.And(schema.Use(float), lambda elevation: elevation >= LOWEST_ALLOWED_ELEVATION), 18 | } 19 | ) 20 | ) 21 | 22 | 23 | def get_location(location_config): 24 | ''' return an Astral location object based on the configured location ''' 25 | LOCATION_CONFIG_SCHEMA.validate(location_config) 26 | 27 | if type(location_config) == str: 28 | # This should be a string that Astral recognizes out of the box 29 | return Astral()[location_config] 30 | else: 31 | location = Location() 32 | location.latitude = location_config['latitude'] 33 | location.longitude = location_config['longitude'] 34 | location.timezone = location_config['timezone'] 35 | location.elevation = location_config['elevation'] 36 | return location 37 | -------------------------------------------------------------------------------- /golden_hour/sunset.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import math 4 | import time 5 | 6 | from astral import Astral 7 | import pytz 8 | 9 | 10 | logger = logging.getLogger() 11 | 12 | 13 | def get_current_time_in_timezone(location): 14 | return datetime.datetime.now( 15 | pytz.timezone(location.timezone) 16 | ) 17 | 18 | 19 | def get_today_sunset_time(location): 20 | today = get_current_time_in_timezone(location).date() 21 | return location.sun(today)['sunset'] 22 | 23 | 24 | def get_seconds_until(earlier_time, later_time): 25 | tdelta = later_time - earlier_time 26 | return tdelta.total_seconds() 27 | 28 | 29 | def wait_for_sunset(location, minutes_before=0): 30 | sunset_time = get_today_sunset_time(location) 31 | start_time = sunset_time - datetime.timedelta(minutes=minutes_before) 32 | 33 | now = get_current_time_in_timezone(location) 34 | if start_time < now: 35 | logger.error('ERROR: too late to start for today\'s sunset') 36 | exit() 37 | 38 | sleep_seconds = get_seconds_until(now, start_time) 39 | hours = math.floor(sleep_seconds // (60 * 60)) 40 | minutes = math.floor((sleep_seconds // 60) % 60) 41 | seconds = math.floor(sleep_seconds % 60) 42 | logger.info( 43 | 'Waiting {hours} {minutes} {seconds} to start, {minutes_before} minutes before sunset'.format( 44 | hours='{} hours'.format(hours) if hours > 0 else '', 45 | minutes='{} minutes'.format(minutes) if minutes > 0 else '', 46 | seconds='{} seconds'.format(seconds), 47 | minutes_before=minutes_before, 48 | ) 49 | ) 50 | time.sleep(sleep_seconds) 51 | -------------------------------------------------------------------------------- /golden_hour/tweet.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import argparse 4 | import logging 5 | import twitter 6 | import yaml 7 | 8 | import schema 9 | 10 | 11 | logger = logging.getLogger() 12 | 13 | def verify_credentials(credentials): 14 | api = twitter.Api(**credentials) 15 | 16 | return api.VerifyCredentials() is not None 17 | 18 | TWITTER_CONFIG_SCHEMA = schema.And( 19 | { 20 | 'consumer_key': str, 21 | 'consumer_secret': str, 22 | 'access_token_key': str, 23 | 'access_token_secret': str, 24 | }, 25 | verify_credentials 26 | ) 27 | 28 | 29 | def post_update(credentials, text, media=None): 30 | TWITTER_CONFIG_SCHEMA.validate(credentials) 31 | 32 | logger.info('posting to twitter (status_text: {}, media: {})'.format(text, media)) 33 | api = twitter.Api(**credentials) 34 | 35 | media_id = None 36 | if media: 37 | with open(media, 'rb') as mediafile: 38 | media_id = api.UploadMediaChunked(mediafile) 39 | 40 | api.PostUpdate(text, media=media_id) 41 | 42 | 43 | def load_credentials_from_file(filepath): 44 | ''' Load credentials from a YAML file. 45 | Supports files with twitter configuration parameters under a "twitter" key, or at the top level. 46 | Expects the parameters to match the format of TWITTER_CONFIG_SCHEMA. 47 | ''' 48 | with open(filepath) as twitter_conf_file: 49 | conf = yaml.load(twitter_conf_file.read()) 50 | 51 | return TWITTER_CONFIG_SCHEMA.validate( 52 | conf['twitter'] if 'twitter' in conf else conf 53 | ) 54 | 55 | 56 | def main(): 57 | parser = argparse.ArgumentParser() 58 | parser.add_argument('text') 59 | parser.add_argument('--media', default=None) 60 | parser.add_argument('--credentials-file', default='twitter_secrets.yaml') 61 | args = parser.parse_args() 62 | 63 | credentials = load_credentials_from_file(args.credentials_file) 64 | post_update(credentials, args.text, args.media) 65 | 66 | 67 | if __name__ == '__main__': 68 | main() 69 | -------------------------------------------------------------------------------- /golden_hour/timelapse/osx.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import subprocess 5 | 6 | from .ffmpeg import compile_video 7 | 8 | 9 | logger = logging.getLogger() 10 | 11 | 12 | def capture(output_dir, duration, interval): 13 | logger.info('capturing one photo every {interval} seconds for {duration} seconds'.format( 14 | duration=duration, 15 | interval=interval, 16 | )) 17 | capture_rate = '1/{}'.format(interval) 18 | output_pattern = '{}/image%05d.png'.format(output_dir) 19 | try: 20 | subprocess.check_call([ 21 | 'ffmpeg', 22 | '-loglevel', 'warning', 23 | '-t', str(duration), 24 | '-f', 'avfoundation', 25 | '-pix_fmt', 'uyvy422', 26 | '-s', '1280x720', 27 | '-framerate', '30', 28 | '-i', 'FaceTime', 29 | '-r', capture_rate, 30 | output_pattern, 31 | ]) 32 | except subprocess.CalledProcessError as error: 33 | logger.error('Error encountered while capturing using ffmpeg', exc_info=True) 34 | 35 | 36 | def main(): 37 | parser = argparse.ArgumentParser(description='Record a timelapse.') 38 | parser.add_argument('--duration', metavar='minutes', required=True, type=int, help='total duration of timelapse capture in minutes') 39 | parser.add_argument('--interval', metavar='seconds', required=True, type=int, help='number of seconds between photo captures') 40 | parser.add_argument('--photos-per-second', type=int, default=30, help='number of photos displayed per second in video') 41 | args = parser.parse_args() 42 | logger.debug(args) 43 | 44 | # capture and compile timelapse 45 | if not os.path.exists('photos'): 46 | os.makedirs('photos') 47 | photos_dir = os.path.abspath('photos') 48 | logger.info('created {}'.format(photos_dir)) 49 | capture(photos_dir, args.duration, args.interval) 50 | compile_video(photos_dir, args.photos_per_second) 51 | # TODO clean up temp dir 52 | 53 | 54 | if __name__ == '__main__': 55 | main() 56 | -------------------------------------------------------------------------------- /golden_hour/timelapse/pi.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import subprocess 5 | 6 | from .ffmpeg import compile_video 7 | 8 | 9 | logger = logging.getLogger() 10 | 11 | 12 | def capture(output_dir, duration, interval): 13 | logger.info('capturing one photo every {interval} seconds for {duration} seconds'.format( 14 | duration=duration, 15 | interval=interval, 16 | )) 17 | output_pattern = '{}/image%05d.png'.format(output_dir) 18 | start_number_files = len(os.listdir(output_dir)) 19 | try: 20 | subprocess.check_call([ 21 | 'raspistill', 22 | '-t', str(duration * 1000), 23 | '-tl', str(interval * 1000), 24 | '-n', # don't try to show a preview window 25 | '-w', '1280', 26 | '-h', '720', 27 | #'--rotation', '180', # set this to correct for camera orientation 28 | '--quality', '100', 29 | # '--verbose', 30 | # '--saturation', '50', 31 | '-o', output_pattern, 32 | '-e', 'png', 33 | ]) 34 | except subprocess.CalledProcessError as error: 35 | logger.error('Error encountered while capturing using raspistill', exc_info=True) 36 | file_list = os.listdir(output_dir) 37 | end_number_files = len(file_list) 38 | logger.info('Captured {count} photos'.format( 39 | count=end_number_files - start_number_files, 40 | )) 41 | 42 | 43 | def main(): 44 | parser = argparse.ArgumentParser(description='Record a timelapse.') 45 | parser.add_argument('--duration', metavar='seconds', required=True, type=int, help='total duration of timelapse capture in seconds') 46 | parser.add_argument('--interval', metavar='seconds', required=True, type=int, help='number of seconds between photo captures') 47 | parser.add_argument('--photos-per-second', type=int, default=30, help='number of photos displayed per second in video') 48 | args = parser.parse_args() 49 | logger.debug(args) 50 | 51 | # capture and compile timelapse 52 | if not os.path.exists('photos'): 53 | os.makedirs('photos') 54 | photos_dir = os.path.abspath('photos') 55 | output_filename = 'timelapse.mp4' 56 | logger.info('created {}'.format(photos_dir)) 57 | capture(photos_dir, args.duration, args.interval) 58 | compile_video(photos_dir, output_filename, args.photos_per_second) 59 | # TODO clean up temp dir 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /authenticate_twitter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2007-2013 The Python-Twitter Developers 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | from six.moves import input 17 | 18 | from requests_oauthlib import OAuth1Session 19 | import logging 20 | import webbrowser 21 | import yaml 22 | 23 | 24 | logger = logging.getLogger() 25 | 26 | 27 | REQUEST_TOKEN_URL = 'https://api.twitter.com/oauth/request_token' 28 | ACCESS_TOKEN_URL = 'https://api.twitter.com/oauth/access_token' 29 | AUTHORIZATION_URL = 'https://api.twitter.com/oauth/authorize' 30 | SIGNIN_URL = 'https://api.twitter.com/oauth/authenticate' 31 | 32 | 33 | def get_access_token(consumer_key, consumer_secret): 34 | oauth_client = OAuth1Session(consumer_key, client_secret=consumer_secret, callback_uri='oob') 35 | 36 | logger.info('\nRequesting temp token from Twitter...\n') 37 | 38 | try: 39 | resp = oauth_client.fetch_request_token(REQUEST_TOKEN_URL) 40 | except ValueError as e: 41 | raise 'Invalid response from Twitter requesting temp token: {0}'.format(e) 42 | 43 | url = oauth_client.authorization_url(AUTHORIZATION_URL) 44 | 45 | logger.info('I will try to start a browser to visit the following Twitter page ' 46 | 'if a browser will not start, copy the URL to your browser ' 47 | 'and retrieve the pincode to be used ' 48 | 'in the next step to obtaining an Authentication Token: \n' 49 | '\n\t{0}'.format(url)) 50 | 51 | webbrowser.open(url) 52 | pincode = input('\nEnter your pincode? ') 53 | 54 | logger.info('\nGenerating and signing request for an access token...\n') 55 | 56 | oauth_client = OAuth1Session(consumer_key, client_secret=consumer_secret, 57 | resource_owner_key=resp.get('oauth_token'), 58 | resource_owner_secret=resp.get('oauth_token_secret'), 59 | verifier=pincode) 60 | try: 61 | resp = oauth_client.fetch_access_token(ACCESS_TOKEN_URL) 62 | except ValueError as e: 63 | raise 'Invalid response from Twitter requesting temp token: {0}'.format(e) 64 | 65 | credentials = { 66 | 'consumer_key': str(consumer_key), 67 | 'consumer_secret': str(consumer_secret), 68 | 'access_token_key': str(resp.get('oauth_token')), 69 | 'access_token_secret': str(resp.get('oauth_token_secret')), 70 | } 71 | credentials_yaml = yaml.dump(credentials, default_flow_style=False) 72 | credentials_filename = 'twitter_secrets.yaml' 73 | with open(credentials_filename, 'w') as f: 74 | f.write(credentials_yaml) 75 | logger.info('Your tokens/keys have been written to {}'.format(credentials_filename)) 76 | 77 | 78 | def main(): 79 | consumer_key = input('Enter your consumer key: ') 80 | consumer_secret = input('Enter your consumer secret: ') 81 | get_access_token(consumer_key, consumer_secret) 82 | 83 | 84 | if __name__ == "__main__": 85 | main() 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # golden-hour 2 | 3 | A python script to generate a timelapse video. Designed specifically to record at sunset, and post to twitter with a weather report. 4 | 5 | ## Setup 6 | 7 | This project assumes that you will run this on [a Raspberry Pi][pi] with a CSI-port camera, although pull requests to broaden that support are certainly accepted. [@goldenhourSEA][goldenhourSEA] runs on a [Pi 3 Model B][model-3] with the [Camera Module V2][camera]. 8 | 9 | [pi]: https://www.raspberrypi.org 10 | [camera]: https://www.raspberrypi.org/products/camera-module-v2/ 11 | [goldenhourSEA]: https://twitter.com/goldenhourSEA 12 | [model-3]: https://www.raspberrypi.org/products/raspberry-pi-3-model-b/ 13 | 14 | ### Installation 15 | 16 | #### Installing [`FFmpeg`][ffmpeg] 17 | 18 | FFmpeg is used to convert the sequence of photos captured by the camera into a video suitable for uploading to Twitter. FFmpeg must be compiled with x264 support. On a Raspberry Pi running Raspbian, simply `sudo apt install ffmpeg`. If you are running this on a Mac, `brew install ffpmeg` should be sufficient. 19 | 20 | [ffmpeg]: http://ffmpeg.org 21 | 22 | #### Installing `golden-hour` 23 | 24 | 1. Check out this repo to a convenient location on your Pi. 25 | 2. Run `pip install .` 26 | 3. Run `golden-hour --help` to check that it's hooked up right! 27 | 28 | #### Configuration 29 | 30 | Configuration data - Twitter and Dark Sky credentials, and location information, will all live in a `.yaml` file. 31 | You can put this wherever you want but we recommend `~/.config/golden-hour.yaml`. 32 | Check out `example_config.yaml` for the expected format of the file. 33 | 34 | ##### Location 35 | 36 | So that `golden-hour` knows when sunset will happen, tell it where the camera is located via the configuration file. For many major cities, you can just specify the city name. 37 | See `example_config.yaml` for the format. 38 | 39 | ##### Twitter 40 | 41 | 1. Create a Twitter account. 42 | - I recommend a name like "goldenhourXYZ", where XYZ is airport code or abbrevation for your city. 43 | - You may want to associate the account with a phone number, to avoid Twitter's anti-spam measures. 44 | 2. Create a [Twitter "app"][twitter-app] for that account. 45 | - Make sure to set the access permissions to "Read and write", otherwise you won't be able to post tweets. 46 | - In the root of the repo, create a `twitter_secrets.yaml` file, and put the consumer key, consumer secret, access token, and access token secret in there: 47 | 48 | [twitter-app]: https://apps.twitter.com 49 | 50 | ##### Dark Sky *(optional)* 51 | 52 | If you want the weather report, [get a Dark Sky API key][dark-sky-api]. 53 | 54 | [dark-sky-api]: https://darksky.net/dev 55 | 56 | #### Running as a one-off 57 | 58 | Once it's installed, run `golden-hour --help` for usage instructions. 59 | If you get the error "`-bash: golden-hour: command not found`", you may need to restart your shell or check that `golden-hour` is installed somewhere on your `PATH`. See "Gotchas" below. 60 | 61 | #### Running automatically 62 | 63 | Once you have everything set up, set up a cron job to run `golden-hour` at the same time every day. Make sure it runs at least one hour before the earliest sunset of the year. You can find this by looking at the "Sun Graph" for your city at timeanddate.com (for example, [here is Seattle](https://www.timeanddate.com/sun/usa/seattle)). 64 | 65 | Example crontab entry (Insert this into your user's crontab with `crontab -e`): 66 | ```cron 67 | 0 15 * * * golden-hour --start-before-sunset 60 --post-to-twitter 68 | ``` 69 | For another example, which uses a specially crafted `.sh` file and a virtualenv, check out [alanhussey's setup](`https://gist.github.com/alanhussey/0f5ccbd1f28e1c7d2c851bff5c496889`) . Note that this may be out of date from the latest version of code in this repo. 70 | 71 | ##### Where are the logs? 72 | 73 | When it is run by `cron`, by default `golden-hour` will send logs to syslog. You can monitor them with `tail -F /var/log/syslog`. 74 | 75 | ##### Gotchas: 76 | 77 | - depending on how you installed `golden-hour`, you will need to make sure that it's on your `PATH`. This may mean adding something like `PATH=~/.local/bin:/usr/local/bin:$PATH` to your crontab and your `~/.bash_profile`, or activating a virtualenv. 78 | - `cron` runs in a different environment from your normal shell. In my case, it did not have access to `ffmpeg`, because I had installed it to `/usr/local/bin`, but the `$PATH` only had `/bin` and `/usr/bin`. 79 | - Your Pi may not be configured to your local timezone. Run `date` to see what time it is for your Pi, and set the cron job to run at an appropriate translated time. I set mine to run at 2300, which is 3pm local time. 80 | -------------------------------------------------------------------------------- /golden_hour/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import datetime 5 | import logging 6 | import logging.handlers 7 | import os 8 | import random 9 | import sys 10 | 11 | from golden_hour import configuration, sunset, timelapse, tweet, weather 12 | from golden_hour.location import get_location 13 | 14 | logger = logging.getLogger() 15 | 16 | def calculate_timelapse_duration(duration, interval, photo_display_rate=30.0): 17 | # return number of seconds 18 | return float(duration) / interval / photo_display_rate 19 | 20 | 21 | def get_random_status_text(): 22 | return random.choice([ 23 | 'wow.', 24 | 'holy moly', 25 | 'what a time to be alive', 26 | 'inconceivable', 27 | 'reverse sunrise', 28 | ]) 29 | 30 | 31 | def get_timelapse_filename(output_dir): 32 | filename_template = '{output_dir}/timelapse_{date}_{count:03d}.mp4' 33 | today_str = datetime.date.today().isoformat() 34 | count = 0 35 | while True: 36 | filename = filename_template.format( 37 | output_dir=output_dir, 38 | date=today_str, 39 | count=count, 40 | ) 41 | if not os.path.exists(filename): 42 | return filename 43 | count += 1 44 | 45 | 46 | def main(): 47 | if sys.stdout.isatty(): 48 | handler = logging.StreamHandler(sys.stdout) 49 | logger.setLevel(logging.DEBUG) 50 | else: 51 | handler = logging.handlers.SysLogHandler(address='/dev/log') 52 | logger.setLevel(logging.INFO) 53 | logger.addHandler(handler) 54 | 55 | parser = argparse.ArgumentParser() 56 | parser.add_argument('--config-file', 57 | default=os.path.expanduser('~/.config/golden-hour.yaml'), 58 | help='configuration file where to find API keys and location information. ' 59 | 'Defaults to ~/.config/golden-hour.yaml' 60 | ) 61 | parser.add_argument('--duration', 62 | metavar='seconds', 63 | type=int, 64 | default=7200, # 2 hours 65 | help='duration of timelapse capture', 66 | ) 67 | # TODO might want to enforce minimum of 3 if using raspi cam 68 | parser.add_argument('--interval', 69 | metavar='seconds', 70 | type=int, 71 | default=8, 72 | help='time between captured photos', 73 | ) 74 | parser.add_argument('--start-before-sunset', 75 | metavar='minutes', 76 | type=int, 77 | default=None, 78 | help='number of minutes before sunset to start timelapse', 79 | ) 80 | parser.add_argument('--post-to-twitter', 81 | action='store_true', 82 | default=False, 83 | help='post video to twitter', 84 | ) 85 | parser.add_argument('--skip-timelapse', 86 | action='store_true', 87 | default=False, 88 | help='skip recording the timelapse (useful for debugging)', 89 | ) 90 | args = parser.parse_args() 91 | 92 | config = configuration.load_configuration(args.config_file) 93 | location = get_location(config['location']) 94 | 95 | output_dir = 'output' 96 | if not os.path.exists(output_dir): 97 | os.mkdir(output_dir) 98 | timelapse_filename = get_timelapse_filename(output_dir) 99 | 100 | if args.post_to_twitter: 101 | twitter_credentials = config['twitter'] 102 | logger.info('verifying twitter credentials') 103 | tweet.TWITTER_CONFIG_SCHEMA.validate(twitter_credentials) 104 | 105 | # check the expected length of the video to make sure it's within twitter's rules 106 | video_duration = calculate_timelapse_duration(args.duration, args.interval) 107 | logger.info('estimated video length: {} seconds'.format(video_duration)) 108 | if video_duration < 5.0: 109 | logger.error('Error: Timelapse video will be too short to upload to Twitter (min 5 seconds)') 110 | exit(1) 111 | if video_duration > 30.0: 112 | logger.error('Error: Timelapse video will be too long to upload to Twitter (max 30 seconds)') 113 | exit(2) 114 | 115 | if args.start_before_sunset is not None: 116 | sunset.wait_for_sunset(location, args.start_before_sunset) 117 | 118 | if not args.skip_timelapse: 119 | timelapse.create_timelapse(args.duration, args.interval, timelapse_filename) 120 | 121 | if 'darksky_key' in config: 122 | darksky_key = config['darksky_key'] 123 | sunset_time = sunset.get_today_sunset_time(location) 124 | forecast = weather.get_sunset_forecast( 125 | darksky_key, 126 | sunset_time, 127 | lat_long=(location.latitude, location.longitude) 128 | ) 129 | status_text = weather.get_status_text(forecast, sunset_time) 130 | else: 131 | status_text = get_random_status_text() 132 | 133 | logger.info(status_text) 134 | 135 | if args.post_to_twitter and not args.skip_timelapse: 136 | tweet.post_update( 137 | config['twitter'], 138 | status_text, 139 | media=timelapse_filename 140 | ) 141 | 142 | logger.info('done!') 143 | 144 | 145 | if __name__ == '__main__': 146 | main() 147 | -------------------------------------------------------------------------------- /golden_hour/weather.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | from random import choice 4 | 5 | from darksky import forecast as get_forecast 6 | 7 | 8 | def get_sunset_forecast(darksky_key, sunset_time, lat_long): 9 | # Get the forecast from *just before* sunset to avoid night-themed emoji 10 | just_before_sunset_time = sunset_time - datetime.timedelta(minutes=10) 11 | with get_forecast(darksky_key, *lat_long, time=just_before_sunset_time.isoformat()) as forecast: 12 | return forecast 13 | 14 | 15 | def get_status_text(forecast, sunset_time): 16 | hourly = forecast['hourly'] 17 | units = forecast['flags']['units'] 18 | currently = forecast['currently'] 19 | 20 | return '\n'.join( 21 | filter(None, [ 22 | '🌅 sunset at {}\n'.format(sunset_time.strftime('%I:%M%p')), 23 | summary(hourly, currently), 24 | temp(currently, units), 25 | cloudiness(currently), 26 | precip(currently), 27 | wind(currently), 28 | visibility(currently), 29 | nearest_storm(currently), 30 | ]) 31 | ) 32 | 33 | 34 | def summary(hourly, currently): 35 | summ = hourly['data'][0]['summary'] 36 | 37 | icon = currently['icon'] 38 | cloud_cover = currently['cloudCover'] 39 | temperature = currently['temperature'] 40 | 41 | return '{} {}'.format( 42 | get_emoji(icon, temperature, cloud_cover), 43 | summ.lower() 44 | ) 45 | 46 | def temp(currently, units): 47 | temperature = currently['temperature'] 48 | apparent_temp = currently['apparentTemperature'] 49 | 50 | feels_like = ( 51 | '' 52 | if round(temperature) == round(apparent_temp) 53 | else ' (feels like {})'.format(display_temp(apparent_temp, units)) 54 | ) 55 | 56 | return '🌡 {}{}'.format( 57 | display_temp(temperature, units), 58 | feels_like 59 | ) 60 | 61 | 62 | def cloudiness(currently): 63 | cloud_cover = currently['cloudCover'] 64 | 65 | if cloud_cover > 0.01: 66 | return '{} {}% cloud cover'.format( 67 | get_cloud_cover_emoji(cloud_cover), 68 | round(cloud_cover * 100) 69 | ) 70 | 71 | def precip(currently): 72 | cloud_cover = currently['cloudCover'] 73 | 74 | precip_prob = currently['precipProbability'] 75 | precip_type = currently.get('precipType') 76 | 77 | if precip_type and precip_prob > 0: 78 | return '{} {}% chance of {}'.format( 79 | get_precip_emoji(precip_type, cloud_cover), 80 | round(precip_prob * 100), 81 | precip_type 82 | ) 83 | 84 | def wind(currently): 85 | wind_speed = currently['windSpeed'] 86 | wind_bearing = currently['windBearing'] 87 | 88 | if wind_speed > 5: 89 | return '💨 winds about {}mph from the {}'.format( 90 | round(wind_speed), 91 | get_bearing(wind_bearing) 92 | ) 93 | 94 | def visibility(currently): 95 | vis = currently['visibility'] 96 | if vis < 5: 97 | return '🌁 {} miles of visibility'.format(vis) 98 | 99 | def nearest_storm(currently): 100 | nearest_storm_distance = currently.get('nearestStormDistance', 0) 101 | nearest_storm_bearing = currently.get('nearestStormBearing') 102 | 103 | if nearest_storm_distance > 0: 104 | return '⛈ nearest storm {} miles to the {}'.format( 105 | nearest_storm_distance, 106 | get_bearing(nearest_storm_bearing) 107 | ) 108 | 109 | def display_temp(temperature, units): 110 | degrees = '℉' if units == 'us' else '℃' 111 | 112 | return str(round(temperature)) + degrees 113 | 114 | 115 | def get_emoji(icon, temperature, cloud_cover): 116 | if icon == 'clear-day': 117 | if temperature > 75: 118 | return choice(['☀️', '☀️', '😎']) 119 | 120 | if temperature < 32: 121 | return choice(['☀️', '☀️', '⛄️']) 122 | 123 | return '☀️' 124 | 125 | if icon == 'rain': 126 | if cloud_cover < 0.5: 127 | return choice(['🌧', '☔️', '🌦']) 128 | 129 | return choice(['🌧', '☔️']) 130 | 131 | return { 132 | 'clear-night': '🌝', 133 | 'snow': choice(['❄️', '🌨', '☃️']), 134 | 'sleet': '🌨', 135 | 'wind': '🌬', 136 | 'fog': '🌁', 137 | 'cloudy': '☁️', 138 | 'partly-cloudy-day': '🌤', 139 | 'partly-cloudy-night': '⛅️', 140 | }.get(icon, '') 141 | 142 | 143 | def get_cloud_cover_emoji(cloud_cover): 144 | if cloud_cover < 0.2: 145 | return '☀️' 146 | 147 | if cloud_cover < 0.5: 148 | return '🌤' 149 | 150 | if cloud_cover < 0.9: 151 | return '🌥' 152 | 153 | return '☁️' 154 | 155 | 156 | def get_precip_emoji(precip_type, cloud_cover): 157 | if precip_type == 'rain': 158 | if (cloud_cover < 0.5): 159 | return choice(['🌧', '☔️', '🌦']) 160 | 161 | return choice(['🌧', '☔️']) 162 | 163 | if precip_type == 'snow': 164 | return choice(['❄️', '🌨', '☃️']) 165 | 166 | if precip_type == 'sleet': 167 | return '🌨' 168 | 169 | return '' 170 | 171 | def get_bearing(degrees, short = False): 172 | directions = ( 173 | 'N,NNE,NE,ENE,E,ESE,SE,SSE,S,SSW,SW,WSW,W,WNW,NW,NNW' 174 | if short 175 | else 'north,northeast,east,southeast,south,southwest,west,northwest' 176 | ).split(',') 177 | 178 | count = len(directions) 179 | 180 | # Distance between each direction 181 | span = 360.0 / count 182 | 183 | # Use modulo to "round" `16` to `0` 184 | index = round(degrees / span) % count 185 | 186 | return directions[index] 187 | --------------------------------------------------------------------------------