Bye Skype
Microsoft is killing Skype in May 2025. I haven't used it for a few years, but I'm going to shed a tear or two.
It's hard to overestimate how important Skype was a little over 20 years ago. I had recently moved to San Francisco and made a lot of phone calls to friends and family back in the UK. There was this thing called a landline, and as well as local service you had to choose a long distance provider with all sorts of complicated tariffs and fees. Cell phones were their own nightmare and even for SMS you paid per text message. Incomprehensibly, ring tones were on their way to being a multi-billion dollar business. My current phone has been on vibrate for its entire life and I travel the world with free high speed Internet included in my plan. Skype marked the beginning of this transition using P2P to open VoIP and video calls to the masses. I even had a cordless phone that made local calls over the phone line but seamlessly switched to Skype for long distance.
Skype's P2P stack could be used for more than phone calls. I built a business to backup your computer over Skype. It's one of the best things I've ever worked on. The Internet was slow for most people. and so you could do a local backup on LAN and then send incrementals P2P via Skype whenever your computer was idle. We also did superior local backups with full history, encryption and locked file support when most competitors choked on PST files. Skype were kind enough to include us as a premium extra in the client. We did some paid search and PR, but the lion's share of our customer acquisition came from this placement.
My current job involves many Teams calls, but before that we operated on hundreds of Skype channels. Before that work was driven by email and conference calls. I'm ambivalent about this shift. Email has some advantages, and hours of video conferences are way more draining than the voice bridge alternative. Teams brilliantly combines channels and chats that look very similar but work completely differently adding an entirely new mental load to the workday. Skype used to display a special cat emoji if you held down three keys at once. I know which philosophy I prefer.
Related Posts
(Published to the Fediverse as: Bye Skype #etc #skype #microsoft Memories of Skype, including building a backup business on it, that mysterious cat emoji, and a landline with Skype built in. We'll miss you! )
Google Photos killed my Aura Frame
I want to do two things with my digital photos. First, keep them safe, especially all those precious memories of random parking meters and unfathomable HVAC mechanisms. Second, enjoy looking at the small subset that are precious family moments. I'm not a special snowflake, these basic requirements should represent a large and competitive market. Unfortunately I'm not holding my breath for much in the way of consumer friendly regulation for the next four years in the US.
Google is making some changes to their Photos API next month, which amount to "Get the fuck out of our Photos API". They're a polite organization so they phrase it a little differently: "We're excited to see the creative solutions developers will build using the new Picker API and the updated Library API.". The developer documentation is a little more pointed: "If your app relies on accessing the user's entire library, you may need to re-evaluate your app or consider alternative approaches."
I have an Aura Carver Mat, a nice digital photo frame that I synced to a shared Google Photos album. Easy for me to add photos, way too easy for the kids to add photos - a fantastic device. As of next month it's ewaste though. I'm not going to upload photos slowly through some picker API like an animal. I'm going to end up building something complicated out of a Raspberry Pi (adds to actuarially unrealistic to do list).
This change doesn't impact backup, because that was already broken. For a while Google Photos nicely integrated with Google Drive and I ended up with a local copy of everything that I could then backup through other means. I'm never going to trust any one company to look after important files and so my philosophy is to backup twice online and once to an external hard drive that lives in a fire safe. (At one point I even built a backup company based on this model).
Google killed the Drive integration and so I MacGyvered together an apps script based solution that used the Google Photos API. This revealed to me that the Photos API would not return location information. Even worse it was impossible to get the full resolution version of a video to download. So it's not like I was in love with the API before this most recent change.
My current approach is a mix of sad and awesome. The sad part is that I use Google Takeout once a month to get an archive of all my online photos. Thankfully this still works. The awesome - I wrote this photo sorter tool that takes the messy download and organizes it by year and month. And I also wrote a volume shadow copy tool that lets you backup a drive without getting hung up on locked files. Those pieces get my photos safely to an external drive, and I upload to Amazon Photos too (the third leg of my backup stool).
Related Posts
- Folder Insights
- Photo Sorter
- How to backup Google Photos to Google Drive automatically after July 2019 with Apps Script
- Leaving the Nest
- Capture DropCam (Nest Cam) frames to Google Drive
(Published to the Fediverse as: Google Photos killed my Aura Frame #etc #google #photos #aura #backup Google is changing their Photos API so my Aura frame is ewaste. Thoughts on using and backing up digital photos. )
Simple Perceptron
Related Posts
- San Francisco Budget - The Missing Manual
- Does closing the Great Highway cause an increase in traffic accidents?
- Life, Non-locality and the Simulation Hypothesis
- Which PG&E rate plan works best for EV charging?
- Scanning from the ADF using WIA in C#
(Published to the Fediverse as: Simple Perceptron #code #ml #perceptron #wml Python notebook illustrating a scratch perceptron implementation as well as an sklearn example. )
DEN SFO
Google Pixel 8 Pro 2mm f2.0 1/4,900s ISO42
UA304
Related Posts
(Published to the Fediverse as: DEN SFO #photo #plane #den #sfo DEN SFO )
BNA DEN
Google Pixel 8 Pro 7mm f1.7 1/250s ISO21
UA1048
Related Posts
(Published to the Fediverse as: BNA DEN #photo #plane #bna #den BNA DEN )
SFO BNA
Google Pixel 8 Pro 7mm f1.7 1/950s ISO21
UA499
Related Posts
(Published to the Fediverse as: SFO BNA #photo #plane #sfo #bna SFO BNA )
Adding AI to Todoist with Google Apps Script and OpenAI
This simple script adds useful AI to Todoist and runs in the cloud on Google Apps Script.
I use Todoist to run my life, and my dream is for it to be able to complete certain types of tasks for me. With OpenAI Operator and Anthropic Computer Use this is getting closer and closer to reality. Yes, there is a risk that Todoist spends all of my money on paper clips. But there is also the upside that it will eventually do my weekly shopping, pay my bills and call back the dentist's office. Google's new Ask for Me is promising too, even if right now it's just going to bother nail salons out of existence.
I already put together an Alexa replacement using a Raspberry Pi and the OpenAI realtime API. It switches lights on and off, adds things to my to do list, figures out when the next L is coming and more (I'll blog more about this soon). One thing I learned is that this kind of thing can get pretty expensive. I can see why Amazon is procrastinating on an LLM Alexa. But costs keep going down, and the future will get more evenly distributed over time.
The first version of this script has two objectives. Respond to tasks, and create calendar events. Here's the script:
// config - ok to leave PERPLEXITY_API_TOKEN blank | |
const OPENAI_API_TOKEN = ''; | |
const PERPLEXITY_API_TOKEN = '' | |
const TODOIST_API_TOKEN = ''; | |
const AI_TASK_LABEL = 'ai'; | |
const AI_MESSAGE_PREFIX = 'AI:'; | |
const MAX_GENERATION_ATTEMPTS = 10; | |
const OPENAI_MODEL = 'gpt-4o' | |
const PERPLEXITY_MODEL = 'sonar-pro' | |
const IMAGE_MIME_TYPES = [ | |
'image/jpeg', | |
'image/jpg', | |
'image/png', | |
'image/webp', | |
'image/gif' | |
]; | |
function trigger() { | |
runAssistant(); | |
} | |
function runAssistant() { | |
// process all open tasks with the AI_TASK_LABEL label | |
tasks = getAiTasks(); | |
tasks.forEach(task => { | |
checkTask(task); | |
}); | |
} | |
function checkTask(task) { | |
// if the user was the last message then we generate a response | |
var responseNeeded = true; | |
const comments = getComments(task.id); | |
comments.forEach(comment => { | |
if (comment?.content) { | |
responseNeeded = !comment.content.startsWith(AI_MESSAGE_PREFIX); | |
} | |
}); | |
if(responseNeeded){ | |
processTask(task, comments); | |
} | |
} | |
function processTask(task, comments) { | |
// build the conversation from the task and comments... | |
// the message array is in openai chat completion format | |
const messages = []; | |
const now = new Date(); | |
const timeZone = Session.getScriptTimeZone(); | |
const timeZoneName = Utilities.formatDate(now, timeZone, "z"); | |
// Format the date. This pattern outputs: "August 10, 2025 20:00:00 PST" | |
const formattedDate = Utilities.formatDate(now, timeZone, "MMMM dd, yyyy HH:mm:ss z"); | |
messages.push({ | |
role: 'developer', | |
content: `You are a helpful assistant that works with Todoist tasks. You are given the current task and any comments and try to help as best as you can. If the user is researching you respond with the information they're looking for. If you have a tool that can help with the task you call it. If you believe that you have fully completed the task then you call the complete_task function to close it. The current task ID is ${task.id}. The current date and time is ${formattedDate}.` | |
}); | |
if (task?.content) { | |
messages.push({ | |
role: 'user', | |
content: task.content | |
}); | |
} | |
if (task?.description) { | |
messages.push({ | |
role: 'user', | |
content: task.description | |
}); | |
} | |
comments.forEach(comment => { | |
if (comment?.content) { | |
if (comment.content.startsWith(AI_MESSAGE_PREFIX)) { | |
// AI message | |
messages.push({ | |
role: 'assistant', | |
content: comment.content.substring(AI_MESSAGE_PREFIX.length).trim() | |
}); | |
} else { | |
// User message - might include an image | |
content = [] | |
// the text part of the comment | |
content.push({type: 'text', text: comment.content}); | |
// if there is an immage attachment add it | |
if (comment.attachment) { | |
if (IMAGE_MIME_TYPES.includes(comment.attachment.file_type)) { | |
var url = ''; | |
if (comment.attachment.tn_l) { | |
// use large thumbnail if it exists | |
url = comment.attachment.tn_l[0]; | |
} else { | |
// full attachment - may fail if too large | |
url = comment.attachment.file_url; | |
} | |
// download to base 64 - openai can't access Todoist attachments from the URL | |
// see https://platform.openai.com/docs/guides/vision#uploading-base64-encoded-images | |
base64 = getAttachment(url); | |
content.push({ | |
type: 'image_url', | |
image_url: { | |
url: `data:${comment.attachment.file_type};base64,${base64}` | |
} | |
}); | |
} | |
} | |
messages.push({ | |
role: 'user', | |
content: content | |
}); | |
} | |
} | |
}); | |
if (messages[messages.length - 1].role = 'user') { | |
// if the user is the last in the thread generate an AI response | |
generateAiResponse(messages, task.id, timeZoneName); | |
} | |
} | |
function generateAiResponse(messages, taskId, timeZoneName) { | |
const payload = { | |
model: OPENAI_MODEL, | |
messages: messages, | |
tools: getTools(timeZoneName) | |
}; | |
// loop to allow for tool use | |
for(var retry = 0; retry < MAX_GENERATION_ATTEMPTS; retry++) { | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + OPENAI_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', options); | |
const result = JSON.parse(response.getContentText()); | |
if (result.choices && result.choices.length > 0) { | |
if (result.choices[0].message.tool_calls && result.choices[0].message.tool_calls.length > 0) { | |
// we have at least one tool request, add the requst message to the conversation | |
payload.messages.push(result.choices[0].message); | |
// run all the tools... | |
result.choices[0].message.tool_calls.forEach(tool_call => { | |
const args = JSON.parse(tool_call['function'].arguments); | |
var result = ''; | |
Logger.log(`Calling ${tool_call['function'].name} (call ID ${tool_call.id})`) | |
switch(tool_call['function'].name) { | |
case 'create_event': | |
try { | |
result = createCalendarAppointment(args.title, args.start, args.end, args?.description, args?.location, args?.guests); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
case 'complete_task': | |
try { | |
result = closeTask(args.task_id); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
case 'answer_question': | |
try { | |
result = generatePerplexityResponse(args.question); | |
} catch (error) { | |
result = error.message; | |
} | |
break; | |
default: | |
result = 'Unknown function?!'; | |
break; | |
} | |
Logger.log(`Tool response: ${result.substring(0, 50)}...`) | |
payload.messages.push({ | |
"role": "tool", | |
"tool_call_id": tool_call.id, | |
"content": result | |
}); | |
}) | |
continue; | |
} else { | |
// message back from AI, post it as a comment to the task | |
const aiMessage = result.choices[0].message.content; | |
Logger.log(`AI Response: ${aiMessage.substring(0, 50)}...`) | |
addComment(taskId, AI_MESSAGE_PREFIX + ' ' + aiMessage) | |
break; | |
} | |
} | |
} | |
} | |
function generatePerplexityResponse(question) { | |
const messages = []; | |
messages.push({ | |
role: 'system', | |
content: 'You are an artificial intelligence assistant and you answer questions for your user. Your answer will be interpreted by another AI so do not inclue any formating or special text in your answer. Be brief and answer the question in a single concise paragraph. You never ask any clarifying questions or suggest any follow up, just respond as best as you can.' | |
}); | |
messages.push({ | |
role: 'user', | |
content: question | |
}); | |
const payload = { | |
model: PERPLEXITY_MODEL, | |
messages: messages | |
}; | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + PERPLEXITY_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
const response = UrlFetchApp.fetch('https://api.perplexity.ai/chat/completions', options); | |
const result = JSON.parse(response.getContentText()); | |
return result.choices[0].message.content; | |
} | |
function getTools(timeZoneName) { | |
tools = []; | |
// complete a todoist task by ID, call closeTask() | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "complete_task", | |
"description": "Closes or completes a task", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"task_id": { | |
"type": "string", | |
"description": "The ID of the task to complete" | |
} | |
}, | |
"required": ["task_id"] | |
} | |
} | |
}); | |
// create a meeting on the user's calendar, call createCalendarAppointment() | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "create_event", | |
"description": "Adds an event to the user's calendar. The start and end timestamps must be in 'MMMM D, YYYY HH:mm:ss z' javascript format", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"title": { | |
"type": "string", | |
"description": "Name of the calendar event, i.e. 'Lunch with Bob'." | |
}, | |
"start": { | |
"type": "string", | |
"description": `Start time for the event, i.e. 'August 10, 2025 20:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify` | |
}, | |
"end": { | |
"type": "string", | |
"description": `End time for the event, i.e. 'August 10, 2025 21:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify, assume 1 hour duration if no end time or length is given` | |
}, | |
"description": { | |
"type": "string", | |
"description": "Optional description for the event" | |
}, | |
"location": { | |
"type": "string", | |
"description": "Optional location for the event" | |
}, | |
"guests": { | |
"type": "string", | |
"description": "Optional comma separated list of email addresses to invite to the event. Never provide an email address unless the user specifically provided it" | |
}, | |
}, | |
"required": ["title", "start", "end"] | |
} | |
} | |
}); | |
if (PERPLEXITY_API_TOKEN){ | |
tools.push({ | |
"type": "function", | |
"function": { | |
"name": "answer_question", | |
"description": "Answers a question using Internet search via the Perplexity Sonar API. Use this for information after your knowlege cutoff date, to reserch questions you do not know the answer to, or for local search.", | |
"parameters": { | |
"type": "object", | |
"properties": { | |
"question": { | |
"type": "string", | |
"description": "The question to answer, i.e. 'How many stars are there in the galaxy?'" | |
} | |
}, | |
"required": ["question"] | |
} | |
} | |
}); | |
} | |
return tools; | |
} | |
function createCalendarAppointment(title, start, end, description, location, guests) { | |
options = {} | |
if (description?.length > 0) { | |
options.description = description; | |
} | |
if (location?.length > 0) { | |
options.location = location; | |
} | |
if (guests?.length > 0) { | |
options.guests = guests; | |
options.sendInvites = true; | |
} | |
CalendarApp.getDefaultCalendar().createEvent(title, | |
new Date(start), | |
new Date(end), | |
options); | |
return `Calendar event ${title} has been created.`; | |
} | |
function closeTask(taskId) { | |
const options = { | |
method: 'post', | |
headers: { | |
'Authorization': 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}; | |
UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks/${taskId}/close`, options); | |
return `Task ${taskId} has been closed. You don't need to do anything else and can move to your next step.`; | |
} | |
function addComment(taskId, comment) { | |
const payload = { | |
task_id: taskId, | |
content: comment | |
}; | |
const options = { | |
method: 'post', | |
contentType: 'application/json', | |
headers: { | |
'Authorization': 'Bearer ' + TODOIST_API_TOKEN | |
}, | |
payload: JSON.stringify(payload) | |
}; | |
UrlFetchApp.fetch('https://api.todoist.com/rest/v2/comments', options); | |
} | |
function getComments(taskId) { | |
var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/comments?task_id=${encodeURIComponent(taskId)}`, { | |
headers: { | |
Authorization: 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}); | |
return JSON.parse(response.getContentText()); | |
} | |
function getAiTasks() { | |
var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks?label=${encodeURIComponent(AI_TASK_LABEL)}`, { | |
headers: { | |
Authorization: 'Bearer ' + TODOIST_API_TOKEN | |
} | |
}); | |
return JSON.parse(response.getContentText()); | |
} | |
function getAttachment(url) { | |
var options = { | |
method: "get", | |
headers: { | |
"Authorization": "Bearer " + TODOIST_API_TOKEN | |
} | |
}; | |
var response = UrlFetchApp.fetch(url, options); | |
var fileBlob = response.getBlob(); // Get the file as a Blob | |
var base64String = Utilities.base64Encode(fileBlob.getBytes()); // Convert Blob to Base64 | |
return base64String; | |
} |
To get this working you need API keys from OpenAi and Todoist. Perplexity is optional, if you have a key add it at the top. It only works with tasks that have the right label, ai is the default - you can change this with AI_TASK_LABEL. I initially developed this with o1, but it looks like the tool use was rushed out too quickly and it calls the same tool repeatedly. GPT-4o works well enough and you can test switching out the model by changing OPENAI_MODEL.
Quick configuration guide - create a Google Apps Script project in Google Drive. Paste in the code above and add your API keys and any other desired configuration changes. Go to Project Settings in the right hand navigation and make sure your time zone is correct. Then go to Triggers and schedule the function trigger to run periodically (I use every 5 minutes). You should be done.
Back in Todoist add the ai label to a task (or whatever label you set in the script) and the AI will respond. With the current script there are two use cases - ask it to create an event (it can invite people, add details to the description, etc.), or ask it to research some aspect of the task you're working on. I think this is helpful because it has the full context of the task, and while you're working in Todoist it's useful to store the history there as well.
The point here is to extend the number of tasks that the script can take on. Add new tools for the AI to consider in the getTools() function, and then wire that tool into an implementation in generateAIResponse(). createCalendarAppointment() is a good example of using built in Google services - as well as the calendar it's pretty easy to interact with email, Google docs and many more. I'm planning to add file uploads as well, and will update this post with iterations of the script that add helpful functionality.
OpenAI recommends around 20 tools as the maximum. At that point it might be necessary to break this up into multiple assistants with different tool collections.
Let me know in the comments if you manage to use this to start crossing anything off your list.
Updated 2025-02-17 01:18:
Updated the script to support images and Perplexity.
Image support takes advantage of multimodal input. Any image attachments will be sent as part of the conversation. This uses the large thumbnail in Todoist by default. It supports JPEG, GIF, PNG and WEBP. If a thumbnail is not available it will send the full size image without resizing, depending on how large this might not be accepted by OpenAI.
Perplexity is implemented as a tool (so OpenAI is always called, and it may optionally call out to Perplexity to get more information). This is useful for web search, local search and knowledge past the training cutoff for the OpenAI model. It's optional, if you don't include a Perplexity API key then it just won't be used.
Here's a simple use case - add a photo of a book and ask Todoist to find the URL where you can order it on Kindle.
More Google Apps Script Projects
- Get an email when your security camera sees something new (Apps Script + Cloud Vision)
- Get an email if your site stops being mobile friendly (no longer available)
- Export Google Fit Daily Steps, Weight and Distance to a Google Sheet
- Email Alerts for new Referers in Google Analytics using Apps Script
- Animation of a year of Global Cloud Cover
- Control LIFX WiFi light bulbs from Google Apps Script
- How to backup Google Photos to Google Drive automatically after July 2019 with Apps Script
- Using the Todoist API to set a due date on the Alexa integration to-do list (with Apps Script)
- Automate Google PageSpeed Insights and Core Web Vitals Logging with Apps Script
- Using the Azure Monitor REST API from Google Apps Script
- Monitor page index status with Google Sheets, Apps Script and the Google Search Console API
(Published to the Fediverse as: Adding AI to Todoist with Google Apps Script and OpenAI #code #ai #openai #todoist #ml #appsscript #gas #google #perplexity How to add an AI assistant to Todoist, includes code to respond to tasks and create calendar appointments with gpt-4o. )
Bringing Sanity to Window Replacement in San Francisco
TLDR: If you live in San Francisco and have windows, please consider signing this letter.
I need to replace my front window. The wood is rotten. While San Francisco never gets that cold, all the cold there is whistles in through the gaps.
In general I want to make my house a little bit more energy efficient whenever I replace something. I'd assumed I could find some nice looking double paned replacements and get on with my life. Sane jurisdictions even require certain levels of insulation for this kind of project. San Francisco went the other way. There is a 14 page guide (PDF) to the requirements. Which boil down to window originalism, if your house is old enough:
"Another significant difference is that vinyl, fiberglass, and aluminum windows often do not have an important detail that is common on most older wood windows: the Ogee (pronounced Oh-jee) lugs at the bottom of the top sash (also called the meeting rail) of a double-hung window."
Yes, when Meta and Red Hat cancelled their conferences in San Francisco I'm pretty sure it was the lack of Ogees.
What about some double paned units?
"There should be an interior space bar, preferably of a dark color, within the insulated unit that visually divides the interior and exterior grilles."
This relates to divided light windows (i.e. you've got multiple panes of glass in one window). You might want to just have a single double paned window, but no, it needs to be in keeping with the original. You might then think that you could put some wood details over that single window but no, just in case someone looks closely and at an angle there has to be a shim inside to simulate it being multiple individual panes.
Doesn't the city care about the environment at all?
"While the advantages of double-paned windows are well known, a properly weatherstripped, single-glazed sash window can greatly reduce or eliminate air, noise and air infiltration (where most energy is lost)."
Greatly reduced is doing a lot of heavy lifting here. Looking at U Values here, the rate of energy transfer in watts per square meter per Kelvin (1°C = 1K) – W/m2K, old single pane windows are over 4.8, modern double paned are 1.3 and triple paned 0.8. Lower is better, almost four times better just for double paned.
I don't know exactly how these requirements came into force. It could be out of touch planners wanting windows for a more civilized era. Maybe there is a concentrated benefit / diffuse cost thing going on in favor of a few eye wateringly expensive custom window builders. But when you're trying to pretend that single paned windows are a boon to the environment something has clearly gone very wrong.
Even at the aesthetic level I'm not sure we need all the Ogees. I love the chaotic architectural chaos of San Francisco. The hot pink victorian next to the grey brutalist remodel. It's part of the charm of the city. Also, the expense of complying with what was popular 100 years ago cannot help with affordability, a key challenge.
Happily my supervisor, Myrna Melgar, has proposed legislation to shred this document and allow most people to choose replacement windows that best fit their needs. It looks like her proposal is currently on a three month vacation with the planning department. If you have windows and live in San Francisco you should probably care about this, and you can sign a letter to show your support for the change here.
Related Posts
- San Francisco November 2016 Propositions
- Save Mount Davidson
- San Francisco and California March 2024 Ballot Measures
- San Francisco November 2020 Ballot Measures
- San Francisco November 2022 Ballot Measures
(Published to the Fediverse as: Bringing Sanity to Window Replacement in San Francisco #politics #sanfrancisco #windows #planning #sfpol San Francisco's insane window replacement rules; legislation to improve the situation; an open letter you can sign to help. )
L Taraval
Time lapse of the L Taraval light rail from West Portal station to the San Francisco Zoo. The L shut down during the pandemic and then years of construction replaced the tracks (and water lines, and sewers). It finally returned in September of 2024. I made a time lapse of the construction phase outside my house.
Related Posts
- L Taraval Track Replacement, The Movie
- Embarcadero
- Timelapse, Week of Jan 31 2022
- Timelapse, Week of Jan 24 2022
- TLOTW #7
(Published to the Fediverse as: L Taraval #timelapse #video #muni #sfmta #sanfrancisco #taraval #westportal #4k Time lapse of the L Taraval from West Portal to the San Francisco Zoo. )
LHR SFO
Google Pixel 8 Pro 18mm f2.8 1/35s ISO16
BA285
In this photograph, an expansive view of a British Airways plane dominates the frame, exuding an aura of readiness and anticipation. The image is captured through the terminal windows, creating a layered effect with reflective surfaces adding depth to the scene. The plane’s curved body and distinctive red, white, and blue tail fin contrast starkly against the soft, muted tones of the cloudy sky beyond. Subtle hints of activity manifest in the blurred figures and equipment around the jet bridge, evoking a quiet hustle and bustle typical of air travel. This juxtaposition of the stationary and the bustling hints at the silent hum of a hub transitioning between departure and arrival.
The composition employs a balanced use of lines and layers. The vertical lines of the window frames serve as a frame within the frame, guiding the viewer’s gaze towards the aircraft, which is centered but appears slightly off-center due to the depth created by the glass. I appreciate the dynamic interaction between the geometric lines of the windows and the curvilinear form of the airplane, drawing attention to both the subject and the structural beauty of the terminal. However, the reflections, while adding depth, slightly distract from the clarity of the plane's details. Overall, this photograph shines in its elegant portrayal of travel's intersections, capturing a moment teeming with possibilities just beyond the glass.
Related Posts
(Published to the Fediverse as: LHR SFO #photo #plane #lhr #sfo Photo of BA 285, from LHR to SFO )