Rewrite search flow and enrich preview assets
build-push / docker (push) Successful in 4m6s

This commit is contained in:
AI Assistant
2026-03-13 12:50:25 +09:00
parent de2488654a
commit b78865d4bf
5 changed files with 432 additions and 268 deletions
+23 -14
View File
@@ -280,7 +280,11 @@ func (a *App) searchMedia(c *gin.Context) {
}
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "ranking thumbnail candidates", "progress": 55})
scored := rankSearchResults(req.Query, results)
rankQuery := req.Query
if len(queryVariants) > 0 {
rankQuery = strings.Join(queryVariants[:min(len(queryVariants), 3)], " ")
}
scored := rankSearchResults(rankQuery, results)
shortlist := scored[:min(len(scored), 10)]
a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing shortlisted thumbnails with Gemini Vision", "progress": 75})
recommended, err := a.GeminiService.Recommend(req.Query, shortlist)
@@ -288,12 +292,13 @@ func (a *App) searchMedia(c *gin.Context) {
fallback := make([]services.AIRecommendation, 0, min(20, len(scored)))
for _, result := range scored[:min(20, len(scored))] {
fallback = append(fallback, services.AIRecommendation{
Title: result.Title,
Link: result.Link,
ThumbnailURL: result.ThumbnailURL,
Source: result.Source,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
Title: result.Title,
Link: result.Link,
ThumbnailURL: result.ThumbnailURL,
PreviewVideoURL: result.PreviewVideoURL,
Source: result.Source,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
})
}
warning := err.Error()
@@ -379,9 +384,12 @@ func rankSearchResults(query string, results []services.SearchResult) []services
if result.ThumbnailURL != "" {
score += 2
}
if result.PreviewVideoURL != "" {
score += 3
}
switch result.Source {
case "Google Video":
score += 1
score -= 1
case "Envato":
score += 7
case "Artgrid":
@@ -419,12 +427,13 @@ func mergeRecommendations(recommended []services.AIRecommendation, ranked []serv
}
seen[item.Link] = true
merged = append(merged, services.AIRecommendation{
Title: item.Title,
Link: item.Link,
ThumbnailURL: item.ThumbnailURL,
Source: item.Source,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
Title: item.Title,
Link: item.Link,
ThumbnailURL: item.ThumbnailURL,
PreviewVideoURL: item.PreviewVideoURL,
Source: item.Source,
Reason: "Keyword-ranked result added without extra Gemini vision tokens.",
Recommended: true,
})
}
return merged
+322 -90
View File
@@ -3,21 +3,24 @@ package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"sync"
"time"
)
type SearchResult struct {
Title string `json:"title"`
Link string `json:"link"`
DisplayLink string `json:"displayLink"`
Snippet string `json:"snippet"`
ThumbnailURL string `json:"thumbnailUrl"`
Source string `json:"source"`
Title string `json:"title"`
Link string `json:"link"`
DisplayLink string `json:"displayLink"`
Snippet string `json:"snippet"`
ThumbnailURL string `json:"thumbnailUrl"`
PreviewVideoURL string `json:"previewVideoUrl"`
Source string `json:"source"`
}
type SearchService struct {
@@ -47,84 +50,169 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
return nil, fmt.Errorf("searxng base url is not configured")
}
sources := []struct {
name string
categories string
engine string
queryBuilder func(string) string
match func(SearchResult) bool
}{
type sourceConfig struct {
name string
categories string
engine string
build func(string) []string
accept func(SearchResult) bool
}
sources := []sourceConfig{
{
name: "Envato",
categories: "general",
engine: s.WebEngine,
build: buildEnvatoQueries,
accept: isRenderableEnvatoResult,
},
{
name: "Artgrid",
categories: "general",
engine: s.WebEngine,
build: buildArtgridQueries,
accept: isRenderableArtgridResult,
},
{
name: "Google Video",
categories: "videos",
engine: s.GoogleVideoEngine,
queryBuilder: func(query string) string {
return buildGoogleVideoQuery(query)
},
match: isUsefulGoogleVideoResult,
},
{
name: "Envato",
categories: "general",
engine: s.WebEngine,
queryBuilder: buildEnvatoQuery,
match: isRenderableEnvatoResult,
},
{
name: "Artgrid",
categories: "general",
engine: s.WebEngine,
queryBuilder: buildArtgridQuery,
match: isRenderableArtgridResult,
build: buildGoogleVideoQueries,
accept: isUsefulGoogleVideoResult,
},
}
seen := map[string]bool{}
results := make([]SearchResult, 0, 60)
results := make([]SearchResult, 0, 90)
var lastErr error
for _, query := range queries {
query = strings.TrimSpace(query)
if query == "" {
baseQueries := limitQueries(queries, 5)
for _, base := range baseQueries {
base = strings.TrimSpace(base)
if base == "" {
continue
}
for _, source := range sources {
searchQuery := source.queryBuilder(query)
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
if err != nil {
lastErr = err
items, err = s.search(searchQuery, source.categories, "", source.name)
}
if err != nil {
lastErr = err
if source.categories != "general" {
items, err = s.search(searchQuery, "general", "", source.name)
for _, searchQuery := range source.build(base) {
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
if err != nil {
lastErr = err
items, err = s.search(searchQuery, source.categories, "", source.name)
}
}
if err != nil {
lastErr = err
continue
}
for _, item := range items {
if item.Link == "" || seen[item.Link] {
if err != nil {
lastErr = err
continue
}
if !source.match(item) {
continue
for _, item := range items {
if item.Link == "" || seen[item.Link] || !source.accept(item) {
continue
}
seen[item.Link] = true
results = append(results, item)
}
seen[item.Link] = true
results = append(results, item)
}
}
}
if len(results) == 0 && lastErr != nil {
return nil, lastErr
}
sort.SliceStable(results, func(i, j int) bool {
return sourceWeight(results[i].Source) > sourceWeight(results[j].Source)
})
if len(results) == 0 && lastErr != nil {
return nil, lastErr
return s.EnrichResults(results), nil
}
func (s *SearchService) EnrichResults(results []SearchResult) []SearchResult {
limit := minInt(len(results), 24)
if limit == 0 {
return results
}
return results, nil
enriched := make([]SearchResult, len(results))
copy(enriched, results)
var wg sync.WaitGroup
sem := make(chan struct{}, 4)
for idx := 0; idx < limit; idx++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
enriched[i] = s.enrichResult(enriched[i])
}(idx)
}
wg.Wait()
return enriched
}
func (s *SearchService) enrichResult(result SearchResult) SearchResult {
switch result.Source {
case "Envato":
return s.enrichEnvato(result)
case "Artgrid":
return s.enrichArtgrid(result)
default:
if result.ThumbnailURL == "" {
result.ThumbnailURL = deriveThumbnail(result.Link)
}
return result
}
}
func (s *SearchService) enrichEnvato(result SearchResult) SearchResult {
html, err := s.fetchText(result.Link)
if err != nil {
return result
}
if result.ThumbnailURL == "" {
result.ThumbnailURL = firstNonEmpty(
extractMetaContent(html, "og:image"),
extractMetaContent(html, "twitter:image"),
)
}
if result.PreviewVideoURL == "" {
result.PreviewVideoURL = extractVideoPreviewURL(html)
}
return result
}
func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult {
clipID := extractArtgridClipID(result.Link)
if clipID == "" {
return result
}
apiURL := "https://artgrid.io/api/clip/details?clipId=" + clipID
body, err := s.fetchJSONText(apiURL)
if err == nil {
urls := collectURLs(body)
if result.ThumbnailURL == "" {
result.ThumbnailURL = pickImageURL(urls)
}
if result.PreviewVideoURL == "" {
result.PreviewVideoURL = pickVideoURL(urls)
}
}
if result.ThumbnailURL == "" || result.PreviewVideoURL == "" {
html, err := s.fetchText(result.Link)
if err == nil {
if result.ThumbnailURL == "" {
result.ThumbnailURL = firstNonEmpty(
extractMetaContent(html, "og:image"),
extractMetaContent(html, "twitter:image"),
)
}
if result.PreviewVideoURL == "" {
result.PreviewVideoURL = extractVideoPreviewURL(html)
}
}
}
return result
}
func (s *SearchService) search(query, categories, engine, source string) ([]SearchResult, error) {
@@ -170,29 +258,40 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
results := make([]SearchResult, 0, len(payload.Results))
for _, item := range payload.Results {
link := strings.TrimSpace(item.URL)
thumb := firstNonEmpty(item.Thumbnail, item.ThumbnailSrc, item.ImgSrc, deriveThumbnail(link))
if link == "" {
continue
}
results = append(results, SearchResult{
Title: item.Title,
Link: link,
DisplayLink: inferDisplayLink(link, item.ParsedURL),
Snippet: item.Content,
ThumbnailURL: thumb,
ThumbnailURL: firstNonEmpty(item.Thumbnail, item.ThumbnailSrc, item.ImgSrc, deriveThumbnail(link)),
Source: normalizeSource(source, link, item.Engine),
})
}
return results, nil
}
func buildGoogleVideoQuery(query string) string {
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR "establishing shot" OR editorial) -tutorial -"how to" -review -reaction -course -podcast -vlog -interview -breakdown -edit -editing`, query)
func buildGoogleVideoQueries(base string) []string {
return []string{
fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR "establishing shot" OR editorial) -tutorial -"how to" -review -reaction -course -podcast -vlog -interview -breakdown -edit -editing`, base),
fmt.Sprintf(`"%s" ("cinematic footage" OR "free stock footage" OR "4k footage") -tutorial -"how to" -review`, base),
}
}
func buildEnvatoQuery(query string) string {
return fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "motion graphics" OR cinematic OR "b-roll") (site:elements.envato.com OR site:videohive.net/item) -site:elements.envato.com/stock-video -site:elements.envato.com/video-templates -site:elements.envato.com/stock-video/stock-footage`, query)
func buildEnvatoQueries(base string) []string {
return []string{
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:elements.envato.com`, base),
fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "b-roll" OR cinematic) site:videohive.net/item`, base),
}
}
func buildArtgridQuery(query string) string {
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, query)
func buildArtgridQueries(base string) []string {
return []string{
fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, base),
fmt.Sprintf(`"%s" ("footage" OR "cinematic" OR "establishing shot") site:artgrid.io/clip/`, base),
}
}
func isUsefulGoogleVideoResult(result SearchResult) bool {
@@ -200,21 +299,13 @@ func isUsefulGoogleVideoResult(result SearchResult) bool {
for _, banned := range []string{
"tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough",
"course", "lesson", "edit tutorial", "editing tutorial", "premiere pro", "after effects",
"breakdown", "explained", "vlog",
"breakdown", "explained", "vlog", "tips", "guide", "learn", "free download",
} {
if strings.Contains(text, banned) {
return false
}
}
for _, desired := range []string{
"b-roll", "stock footage", "cinematic", "footage", "establishing shot", "4k",
} {
if strings.Contains(text, desired) {
return true
}
}
lowerLink := strings.ToLower(result.Link)
return strings.Contains(lowerLink, "youtube.com/watch") || strings.Contains(lowerLink, "youtu.be/")
return true
}
func isRenderableEnvatoResult(result SearchResult) bool {
@@ -225,7 +316,7 @@ func isRenderableEnvatoResult(result SearchResult) bool {
host := strings.ToLower(parsed.Host)
path := strings.Trim(parsed.Path, "/")
if strings.Contains(host, "videohive.net") {
return strings.HasPrefix(path, "item/")
return strings.HasPrefix(path, "item/") && len(strings.Split(path, "/")) >= 2
}
if strings.Contains(host, "elements.envato.com") {
if path == "" || strings.Contains(path, "/") {
@@ -244,17 +335,7 @@ func isRenderableArtgridResult(result SearchResult) bool {
if !strings.Contains(strings.ToLower(parsed.Host), "artgrid.io") {
return false
}
path := strings.Trim(parsed.Path, "/")
return regexp.MustCompile(`^clip/[0-9]+/`).MatchString(path)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
return regexp.MustCompile(`^/clip/[0-9]+/`).MatchString(parsed.Path)
}
func normalizeSource(source, link, engine string) string {
@@ -305,6 +386,150 @@ func extractYouTubeID(link string) string {
return ""
}
func extractMetaContent(html, property string) string {
patterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)<meta[^>]+property=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']+)`),
regexp.MustCompile(`(?i)<meta[^>]+name=["']` + regexp.QuoteMeta(property) + `["'][^>]+content=["']([^"']+)`),
}
for _, pattern := range patterns {
matches := pattern.FindStringSubmatch(html)
if len(matches) == 2 {
return htmlUnescape(matches[1])
}
}
return ""
}
func extractVideoPreviewURL(html string) string {
pattern := regexp.MustCompile(`https?:\\?/\\?/[^"'\\s>]+(?:mp4|m3u8)`)
matches := pattern.FindAllString(html, -1)
for _, match := range matches {
candidate := strings.ReplaceAll(match, `\/`, `/`)
candidate = strings.ReplaceAll(candidate, `\u002F`, `/`)
candidate = strings.ReplaceAll(candidate, `\\`, "")
if strings.Contains(strings.ToLower(candidate), "preview") || strings.Contains(strings.ToLower(candidate), "video") {
return candidate
}
}
return ""
}
func extractArtgridClipID(link string) string {
matches := regexp.MustCompile(`/clip/([0-9]+)/`).FindStringSubmatch(link)
if len(matches) == 2 {
return matches[1]
}
return ""
}
func collectURLs(body string) []string {
pattern := regexp.MustCompile(`https?:\/\/[^"'\\\s]+`)
matches := pattern.FindAllString(body, -1)
seen := map[string]bool{}
results := make([]string, 0, len(matches))
for _, match := range matches {
candidate := strings.TrimSpace(strings.Trim(match, `"'`))
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
results = append(results, candidate)
}
return results
}
func pickImageURL(urls []string) string {
for _, item := range urls {
lower := strings.ToLower(item)
if strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg") || strings.Contains(lower, ".png") || strings.Contains(lower, ".webp") {
return item
}
}
return ""
}
func pickVideoURL(urls []string) string {
for _, item := range urls {
lower := strings.ToLower(item)
if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".m3u8") {
return item
}
}
return ""
}
func (s *SearchService) fetchText(target string) (string, error) {
resp, err := s.Client.Get(target)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", fmt.Errorf("fetch returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return "", err
}
return string(data), nil
}
func (s *SearchService) fetchJSONText(target string) (string, error) {
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json, text/json")
resp, err := s.Client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", fmt.Errorf("json fetch returned status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if err != nil {
return "", err
}
return string(data), nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func limitQueries(queries []string, limit int) []string {
seen := map[string]bool{}
filtered := make([]string, 0, minInt(len(queries), limit))
for _, item := range queries {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if seen[key] {
continue
}
seen[key] = true
filtered = append(filtered, trimmed)
if len(filtered) >= limit {
break
}
}
return filtered
}
func htmlUnescape(text string) string {
replacer := strings.NewReplacer("&amp;", "&", "&quot;", `"`, "&#39;", "'", "&lt;", "<", "&gt;", ">")
return replacer.Replace(text)
}
func sourceWeight(source string) int {
switch source {
case "Envato":
@@ -317,3 +542,10 @@ func sourceWeight(source string) int {
return 0
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
+68 -161
View File
@@ -9,6 +9,9 @@ import (
"mime"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
@@ -19,12 +22,13 @@ type GeminiService struct {
}
type AIRecommendation struct {
Title string `json:"title"`
Link string `json:"link"`
ThumbnailURL string `json:"thumbnailUrl"`
Source string `json:"source"`
Reason string `json:"reason"`
Recommended bool `json:"recommended"`
Title string `json:"title"`
Link string `json:"link"`
ThumbnailURL string `json:"thumbnailUrl"`
PreviewVideoURL string `json:"previewVideoUrl"`
Source string `json:"source"`
Reason string `json:"reason"`
Recommended bool `json:"recommended"`
}
type QueryExpansion struct {
@@ -39,141 +43,8 @@ func NewGeminiService(apiKey string) *GeminiService {
}
func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
if g.APIKey == "" {
return fallbackQueryExpansion(query, query), nil
}
englishBase := g.TranslateQuery(query)
body := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{
{
"text": "You are a JSON-only API. Output valid JSON only. Never add prose, labels, markdown, or explanations before or after the JSON.",
},
},
},
"contents": []map[string]any{
{
"parts": []map[string]string{
{
"text": `Return JSON only in this shape: {"querywords":["..."]}.
Generate at most 10 concise English search variations for media discovery across Google Video, Envato, and Artgrid.
The queries must be usable directly in English search engines for stock footage discovery.
Prioritize media, video footage, stock footage, cinematic b-roll, editorial footage, and scene-based search terms.
Avoid celebrity gossip, reaction-style phrasing, clickbait phrasing, and generic web search wording.
Do not output Korean unless it is part of a proper noun.
Original user query: ` + query + `
English base translation: ` + englishBase,
},
},
},
},
"generationConfig": map[string]any{
"responseMimeType": "application/json",
"temperature": 0.2,
"maxOutputTokens": 220,
"responseSchema": map[string]any{
"type": "OBJECT",
"properties": map[string]any{
"querywords": map[string]any{
"type": "ARRAY",
"items": map[string]any{
"type": "STRING",
},
},
},
"required": []string{"querywords"},
},
},
}
rawText, err := g.generateText(body)
if err != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
jsonText, err := extractJSONObject(rawText)
if err != nil {
strictBody := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{
{
"text": "You are a strict JSON emitter. Output one valid JSON object only. Do not write any other text.",
},
},
},
"contents": []map[string]any{
{
"parts": []map[string]string{
{
"text": `STRICT JSON ONLY.
Output must start with { and end with }.
Do not add prose, explanations, markdown, code fences, or labels.
Return exactly this shape: {"querywords":["..."]}.
Generate up to 10 search queries for media discovery across Google Video, Envato, and Artgrid.
Every query must be in natural English and suitable for stock-footage search.
Original user query: ` + query + `
English base translation: ` + englishBase,
},
},
},
},
"generationConfig": map[string]any{
"responseMimeType": "application/json",
"temperature": 0.1,
"maxOutputTokens": 220,
"responseSchema": map[string]any{
"type": "OBJECT",
"properties": map[string]any{
"querywords": map[string]any{
"type": "ARRAY",
"items": map[string]any{
"type": "STRING",
},
},
},
"required": []string{"querywords"},
},
},
}
rawText, retryErr := g.generateText(strictBody)
if retryErr != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
jsonText, err = extractJSONObject(rawText)
if err != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
}
var parsed QueryExpansion
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
return fallbackQueryExpansion(query, englishBase), nil
}
queries := fallbackQueryExpansion(query, englishBase)
seen := map[string]bool{}
englishOnly := !strings.EqualFold(strings.TrimSpace(englishBase), strings.TrimSpace(query))
for _, existing := range queries {
seen[strings.ToLower(strings.TrimSpace(existing))] = true
}
for _, item := range parsed.Querywords {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if englishOnly && !isLikelyEnglishQuery(trimmed) {
continue
}
key := strings.ToLower(trimmed)
if seen[key] {
continue
}
seen[key] = true
queries = append(queries, trimmed)
}
return queries, nil
return buildSearchQueries(query, englishBase), nil
}
func (g *GeminiService) TranslateQuery(query string) string {
@@ -277,7 +148,7 @@ User query: ` + query,
maxImages := min(len(candidates), 10)
for idx := 0; idx < maxImages; idx++ {
img, mimeType, err := fetchImageAsInlineData(g.Client, candidates[idx].ThumbnailURL)
img, mimeType, err := fetchCandidateVisualInlineData(g.Client, candidates[idx])
if err != nil {
continue
}
@@ -348,24 +219,26 @@ User query: ` + query,
}
src := candidates[rec.Index]
recommendations = append(recommendations, AIRecommendation{
Title: src.Title,
Link: src.Link,
ThumbnailURL: src.ThumbnailURL,
Source: src.Source,
Reason: rec.Reason,
Recommended: true,
Title: src.Title,
Link: src.Link,
ThumbnailURL: src.ThumbnailURL,
PreviewVideoURL: src.PreviewVideoURL,
Source: src.Source,
Reason: rec.Reason,
Recommended: true,
})
}
if len(recommendations) == 0 {
for _, candidate := range candidates[:min(4, len(candidates))] {
recommendations = append(recommendations, AIRecommendation{
Title: candidate.Title,
Link: candidate.Link,
ThumbnailURL: candidate.ThumbnailURL,
Source: candidate.Source,
Reason: "Fallback result because Gemini returned no recommended items.",
Recommended: true,
Title: candidate.Title,
Link: candidate.Link,
ThumbnailURL: candidate.ThumbnailURL,
PreviewVideoURL: candidate.PreviewVideoURL,
Source: candidate.Source,
Reason: "Fallback result because Gemini returned no recommended items.",
Recommended: true,
})
}
}
@@ -374,6 +247,9 @@ User query: ` + query,
}
func fetchImageAsInlineData(client *http.Client, imageURL string) (string, string, error) {
if strings.TrimSpace(imageURL) == "" {
return "", "", fmt.Errorf("image url is empty")
}
resp, err := client.Get(imageURL)
if err != nil {
return "", "", err
@@ -397,6 +273,40 @@ func fetchImageAsInlineData(client *http.Client, imageURL string) (string, strin
return base64.StdEncoding.EncodeToString(data), mimeType, nil
}
func fetchCandidateVisualInlineData(client *http.Client, candidate SearchResult) (string, string, error) {
if candidate.ThumbnailURL != "" {
data, mimeType, err := fetchImageAsInlineData(client, candidate.ThumbnailURL)
if err == nil {
return data, mimeType, nil
}
}
if candidate.PreviewVideoURL != "" {
return extractFrameFromVideo(candidate.PreviewVideoURL)
}
return "", "", fmt.Errorf("candidate has no thumbnail or preview video")
}
func extractFrameFromVideo(videoURL string) (string, string, error) {
tempDir, err := os.MkdirTemp("", "gemini-frame-*")
if err != nil {
return "", "", err
}
defer os.RemoveAll(tempDir)
framePath := filepath.Join(tempDir, "frame.jpg")
cmd := exec.Command("ffmpeg", "-y", "-ss", "00:00:00.500", "-i", videoURL, "-frames:v", "1", "-q:v", "2", framePath)
output, err := cmd.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("ffmpeg frame extraction failed: %s", strings.TrimSpace(string(output)))
}
data, err := os.ReadFile(framePath)
if err != nil {
return "", "", err
}
return base64.StdEncoding.EncodeToString(data), "image/jpeg", nil
}
func min(a, b int) int {
if a < b {
return a
@@ -457,27 +367,24 @@ func truncateForError(text string, limit int) string {
return trimmed[:limit] + "..."
}
func fallbackQueryExpansion(originalQuery, englishQuery string) []string {
func buildSearchQueries(originalQuery, englishQuery string) []string {
base := strings.TrimSpace(englishQuery)
if base == "" {
base = strings.TrimSpace(originalQuery)
}
candidates := []string{
base,
base + " b-roll",
strings.ReplaceAll(base, "pov", "point of view"),
base + " stock footage",
base + " b-roll",
base + " cinematic footage",
base + " establishing shot",
base + " editorial footage",
base + " urban scene",
base + " ambient footage",
base + " 4k footage",
base + " cinematic b-roll",
base + " establishing shot",
}
seen := map[string]bool{}
queries := make([]string, 0, len(candidates))
for _, item := range candidates {
trimmed := strings.TrimSpace(item)
trimmed := strings.TrimSpace(strings.Join(strings.Fields(item), " "))
if trimmed == "" {
continue
}
+17 -2
View File
@@ -133,12 +133,27 @@ function renderResults(results) {
}
for (const item of results) {
const node = cardTemplate.content.firstElementChild.cloneNode(true);
const image = node.querySelector("img");
const previewVideo = node.querySelector(".preview-hover");
node.href = item.link;
node.querySelector("img").src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=No+Preview";
node.querySelector("img").alt = item.title;
image.src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
image.alt = item.title;
node.querySelector("h3").textContent = item.title;
node.querySelector("p").textContent = item.reason;
node.querySelector(".source-badge").textContent = item.source;
if (item.previewVideoUrl) {
previewVideo.src = item.previewVideoUrl;
previewVideo.poster = item.thumbnailUrl || "";
node.addEventListener("mouseenter", () => {
previewVideo.classList.remove("hidden");
previewVideo.play().catch(() => {});
});
node.addEventListener("mouseleave", () => {
previewVideo.pause();
previewVideo.currentTime = 0;
previewVideo.classList.add("hidden");
});
}
searchResults.appendChild(node);
}
}
+2 -1
View File
@@ -128,6 +128,7 @@
<a target="_blank" rel="noreferrer" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 transition hover:border-white/30">
<div class="relative aspect-video overflow-hidden bg-zinc-900">
<img class="h-full w-full object-cover transition duration-500 group-hover:scale-105" alt="" />
<video class="preview-hover absolute inset-0 hidden h-full w-full object-cover" muted loop playsinline preload="none"></video>
<div class="absolute left-3 top-3 rounded-full border border-white/20 bg-black/60 px-3 py-1 text-[11px] uppercase tracking-[0.25em] text-white">AI Recommended</div>
<div class="source-badge absolute bottom-3 left-3 rounded-full bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-black"></div>
</div>
@@ -138,6 +139,6 @@
</a>
</template>
<script src="/app.js?v=20260313d" defer></script>
<script src="/app.js?v=20260313e" defer></script>
</body>
</html>