Bild Carousel

This commit is contained in:
2026-07-09 16:45:02 +02:00
parent 9be9b2987b
commit 1a7ebdc3b6
15 changed files with 347 additions and 0 deletions

View File

@@ -1332,6 +1332,211 @@ app.get('/api/tiktok/status', async (request, response) => {
});
});
// ─── TikTok Upload Helpers ─────────────────────────────────────────────────
const getLiveTiktokAccessToken = async () => {
const tokens = await getTiktokTokens(db);
if (!tokens) {
const error = new Error('No TikTok account connected.');
error.status = 404;
throw error;
}
const expiresAt = new Date(tokens.access_token_expires_at).getTime();
if (expiresAt - Date.now() < TIKTOK_REFRESH_BUFFER_MS) {
const { clientKey, clientSecret } = getTiktokEnv();
if (!clientKey || !clientSecret) {
const error = new Error('TikTok client credentials are not configured.');
error.status = 500;
throw error;
}
return refreshTiktokTokens(db, { clientKey, clientSecret });
}
return tokens;
};
const tiktokApi = async (url, options = {}) => {
const tokens = await getLiveTiktokAccessToken();
const accessToken = tokens.access_token;
const fetchOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
};
const res = await fetch(url, fetchOptions);
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
if (!res.ok || data?.error?.code !== 'ok') {
const message = data?.error?.message || data?.raw || `TikTok API error: ${res.status}`;
const error = new Error(message);
error.status = res.status;
error.body = data;
throw error;
}
return data;
};
const uploadBinaryToTiktok = async (uploadUrl, buffer, mimeType = 'video/mp4') => {
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': mimeType,
'Content-Length': String(buffer.length),
},
body: buffer,
});
const text = await res.text();
if (!res.ok && res.status !== 201) {
const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
error.status = res.status;
throw error;
}
return { status: res.status, body: text };
};
// ─── TikTok Video Upload ────────────────────────────────────────────────────
app.post('/api/tiktok/upload/video', async (request, response) => {
try {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const { videoBuffer, mimeType = 'video/mp4' } = request.body || {};
if (!Buffer.isBuffer(videoBuffer)) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'videoBuffer must be a binary buffer.' });
}
const videoSize = videoBuffer.length;
const initBody = {
source_info: {
source: 'FILE_UPLOAD',
video_size: videoSize,
chunk_size: videoSize,
total_chunk_count: 1,
},
};
const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', {
method: 'POST',
body: Buffer.from(JSON.stringify(initBody)),
});
const uploadUrl = initResult?.data?.upload_url;
const publishId = initResult?.data?.publish_id;
if (!uploadUrl || !publishId) {
return response.status(502).json({ code: 'PROVIDER_ERROR', message: 'Missing upload_url or publish_id from TikTok.' });
}
await uploadBinaryToTiktok(uploadUrl, videoBuffer, mimeType || 'video/mp4');
response.status(200).json({
publish_id: publishId,
upload_url: uploadUrl,
status: 'INITIATED',
});
} catch (error) {
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}
});
// ─── TikTok Photo/Carousel Upload ───────────────────────────────────────────
app.post('/api/tiktok/upload/photo', async (request, response) => {
try {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const files = Array.isArray(request.body?.files) ? request.body.files : [];
if (!files.length) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'files must be a non-empty array.' });
}
if (files.length > 35) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'TikTok allows up to 35 photos per carousel post.' });
}
const fileInfos = [];
for (const file of files) {
const buffer = file.buffer || file.data;
const mimeType = file.contentType || file.mimeType || 'image/jpeg';
if (!Buffer.isBuffer(buffer)) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'Each file must include a binary buffer.' });
}
const uploadRes = await uploadImage(buffer.toString('base64'), mimeType);
fileInfos.push({
url: uploadRes.url,
mime_type: mimeType,
});
}
const initBody = {
media_type: 'PHOTO',
photo_cover_index: 0,
file_paths: fileInfos.map((info) => info.url),
file_extensions: fileInfos.map((info) => (info.mime_type || 'image/jpeg').split('/').pop()),
post_mode: 'DIRECT_POST',
};
const initResult = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/content/init/', {
method: 'POST',
body: Buffer.from(JSON.stringify(initBody)),
});
response.status(200).json({
publish_id: initResult?.data?.publish_id,
status: initResult?.data?.status || 'INITIATED',
});
} catch (error) {
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}
});
// ─── TikTok Upload Status ───────────────────────────────────────────────────
app.get('/api/tiktok/upload/status', async (request, response) => {
try {
if (!isAuthorizedAdminNavigation(request)) {
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' });
}
const publishId = String(request.query.publish_id || '').trim();
if (!publishId) {
return response.status(400).json({ code: 'BAD_REQUEST', message: 'publish_id is required.' });
}
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
method: 'POST',
body: Buffer.from(JSON.stringify({ publish_id: publishId })),
});
response.status(200).json(result);
} catch (error) {
const payload = toApiErrorPayload(error);
response.status(payload.status).json(payload.body);
}
});
// ─── Startup ───────────────────────────────────────────────────────────────
app.delete('/auth/account', async (request, response) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1,63 @@
GreenLens Educational Social Pack 2026-07-09
Status: Draft/Preview, keine Live-Posts.
Variante: B — Vintage Botanical Infographic
A/B-Logik: letzter erkannter GreenLens-Lauf 2026-07-08 war A; dieser Lauf wechselt auf B.
Thema: Yellow leaves? Check roots first — Wasser, Licht, Wurzeln, Fungus Gnats.
Format: 7 Slides, 1086x1448 (3:4), plus 18s Carousel-Video, 24fps, H.264, 0.5s Crossfade.
Interne Faktenbasis: UC IPM + Iowa State Extension nennen u.a. Lichtmangel, Überwässerung/schlechte Drainage, Wurzelschäden und bodennahe Insekten/Krankheiten als mögliche Ursachen für gelbe/abfallende Blätter. Keine Quellen in Public Copy.
Caption IG/TikTok:
Yellow leaves are a symptom, not a diagnosis.
Before changing everything, check the roots, soil moisture, light, and leaf undersides. A wet pot in a low-light corner can look like a leaf problem — but the stress often starts below the soil.
Save this for your next plant check.
#PlantCare #Houseplants #GreenLens #PlantTips
Facebook Caption:
Yellow leaves often point to a routine problem: soil staying wet too long, lower light, root stress, or pests hiding early. Check one cause at a time before making big changes.
#PlantCare #Houseplants #PlantTips #GreenLens
X/Twitter Caption:
Yellow leaves? Dont guess from the leaf alone.
Quick check:
1. Is the soil staying wet?
2. Do roots smell off or look dark?
3. Did light change recently?
4. Any tiny gnats or pest dots?
Adjust one thing at a time.
greenlenspro.com
A/B-Test-Hinweis:
Test zuerst Variante B auf Saves/Kommentare gegen den letzten A-Run. Alternative Hook für nächsten A-Lauf: “Yellow leaves? Check the pot, not the leaf.”
Messung:
- Saves
- Kommentare mit Symptomen/Fragen
- Profilklicks
- Website-Klicks greenlenspro.com
Asset Mapping:
- IG/TikTok Carousel: Slides 0107
- TikTok/IG Reel/Slideshow: C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_carousel_1086x1448.mp4
- Facebook: 1:1 Crop aus Mitte von Slide 01 oder Carousel Slides 0107
- X/Twitter: Bildsequenz Slides 0104 oder Text-only Caption
Dateien:
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_01.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_02.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_03.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_04.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_05.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_06.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_slide_07.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_contact_sheet.png
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_carousel_1086x1448.mp4
C:\Users\timo\AppData\Local\hermes\cache\images\greenlens_social_2026-07-09\greenlens_yellow_roots_subtitles.vtt

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -0,0 +1,30 @@
WEBVTT
1
00:00:00.000 --> 00:00:03.000
Yellow leaves? Check roots first. Yellowing often starts below the soil line, not on the leaf.
2
00:00:02.500 --> 00:00:05.500
Wet soil can block air. Roots need oxygen. If the pot stays wet, leaves may yellow and drop.
3
00:00:05.000 --> 00:00:08.000
Low light slows drying. A darker corner can make normal watering feel like overwatering.
4
00:00:07.500 --> 00:00:10.500
Gnats point to damp soil. Fungus gnats often appear when the top layer stays moist too long.
5
00:00:10.000 --> 00:00:13.000
Check three places. Soil moisture, root smell, and leaf undersides tell the clearest story.
6
00:00:12.500 --> 00:00:15.500
Fix the routine slowly. Adjust light, drainage, and watering one step at a time.
7
00:00:15.000 --> 00:00:18.000
Get plant diagnosis. greenlenspro.com

View File

@@ -0,0 +1,47 @@
{
"date": "2026-07-09",
"brand": "GreenLens Pro",
"topic": "Yellow leaves: roots, water, light and pests",
"variant": "B — vintage botanical infographic",
"dimensions": "1086x1448",
"slides": [
{
"top": "Yellow leaves? Check roots first",
"bottom": "Yellowing often starts below the soil line, not on the leaf."
},
{
"top": "Wet soil can block air",
"bottom": "Roots need oxygen. If the pot stays wet, leaves may yellow and drop."
},
{
"top": "Low light slows drying",
"bottom": "A darker corner can make normal watering feel like overwatering."
},
{
"top": "Gnats point to damp soil",
"bottom": "Fungus gnats often appear when the top layer stays moist too long."
},
{
"top": "Check three places",
"bottom": "Soil moisture, root smell, and leaf undersides tell the clearest story."
},
{
"top": "Fix the routine slowly",
"bottom": "Adjust light, drainage, and watering one step at a time."
},
{
"top": "Get plant diagnosis",
"bottom": "greenlenspro.com",
"cta": true
}
],
"files": [
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_01.png",
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_02.png",
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_03.png",
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_04.png",
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_05.png",
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_06.png",
"C:\\Users\\timo\\AppData\\Local\\hermes\\cache\\images\\greenlens_social_2026-07-09\\greenlens_yellow_roots_slide_07.png"
]
}

View File

@@ -0,0 +1,2 @@
B — vintage botanical infographic
Previous detected run 2026-07-08 was A, so this run alternates to B.