Improve translated search flow and modal layout
build-push / docker (push) Successful in 4m19s

This commit is contained in:
AI Assistant
2026-03-13 11:57:58 +09:00
parent 1fc06fb785
commit 6852e07607
4 changed files with 186 additions and 70 deletions
+6 -4
View File
@@ -348,7 +348,9 @@ func rankSearchResults(query string, results []services.SearchResult) []services
} }
negativeTerms := []string{ negativeTerms := []string{
"shocking", "amazing", "crazy", "must watch", "reaction", "gossip", "celebrity", "shocking", "amazing", "crazy", "must watch", "reaction", "gossip", "celebrity",
"thumbnail", "meme", "prank", "drama", "breaking", "viral", "thumbnail", "meme", "prank", "drama", "breaking", "viral", "tutorial",
"how to", "review", "walkthrough", "course", "lesson", "podcast", "interview",
"premiere pro", "after effects", "explained", "breakdown", "vlog",
} }
type scoredResult struct { type scoredResult struct {
item services.SearchResult item services.SearchResult
@@ -379,11 +381,11 @@ func rankSearchResults(query string, results []services.SearchResult) []services
} }
switch result.Source { switch result.Source {
case "Google Video": case "Google Video":
score += 3 score += 2
case "Envato": case "Envato":
score += 4 score += 5
case "Artgrid": case "Artgrid":
score += 4 score += 5
} }
scored = append(scored, scoredResult{item: result, score: score}) scored = append(scored, scoredResult{item: result, score: score})
} }
+73 -47
View File
@@ -52,30 +52,30 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
categories string categories string
engine string engine string
queryBuilder func(string) string queryBuilder func(string) string
match func(string) bool match func(SearchResult) bool
}{ }{
{ {
name: "Google Video", name: "Google Video",
categories: "videos", categories: "videos",
engine: s.GoogleVideoEngine, engine: s.GoogleVideoEngine,
queryBuilder: func(query string) string { queryBuilder: func(query string) string {
return query return buildGoogleVideoQuery(query)
}, },
match: func(string) bool { return true }, match: isUsefulGoogleVideoResult,
}, },
{ {
name: "Envato", name: "Envato",
categories: "general", categories: "general",
engine: s.WebEngine, engine: s.WebEngine,
queryBuilder: buildEnvatoQuery, queryBuilder: buildEnvatoQuery,
match: isEnvatoURL, match: isRenderableEnvatoResult,
}, },
{ {
name: "Artgrid", name: "Artgrid",
categories: "general", categories: "general",
engine: s.WebEngine, engine: s.WebEngine,
queryBuilder: buildArtgridQuery, queryBuilder: buildArtgridQuery,
match: func(link string) bool { return strings.Contains(strings.ToLower(link), "artgrid.io") }, match: isRenderableArtgridResult,
}, },
} }
@@ -88,10 +88,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
continue continue
} }
for _, source := range sources { for _, source := range sources {
searchQuery := query searchQuery := source.queryBuilder(query)
if source.queryBuilder != nil {
searchQuery = source.queryBuilder(query)
}
items, err := s.search(searchQuery, source.categories, source.engine, source.name) items, err := s.search(searchQuery, source.categories, source.engine, source.name)
if err != nil { if err != nil {
@@ -112,10 +109,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
if item.Link == "" || seen[item.Link] { if item.Link == "" || seen[item.Link] {
continue continue
} }
if source.match != nil && !source.match(item.Link) { if !source.match(item) {
continue
}
if !isRenderableLink(item.Link, item.Source) {
continue continue
} }
seen[item.Link] = true seen[item.Link] = true
@@ -138,7 +132,7 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
values.Set("q", query) values.Set("q", query)
values.Set("format", "json") values.Set("format", "json")
values.Set("safesearch", "0") values.Set("safesearch", "0")
values.Set("language", "ko-KR") values.Set("language", "en-US")
if categories != "" { if categories != "" {
values.Set("categories", categories) values.Set("categories", categories)
} }
@@ -189,6 +183,71 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
return results, nil 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 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 buildArtgridQuery(query string) string {
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, query)
}
func isUsefulGoogleVideoResult(result SearchResult) bool {
text := strings.ToLower(result.Title + " " + result.Snippet)
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",
} {
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/")
}
func isRenderableEnvatoResult(result SearchResult) bool {
parsed, err := url.Parse(result.Link)
if err != nil {
return false
}
host := strings.ToLower(parsed.Host)
path := strings.Trim(parsed.Path, "/")
if strings.Contains(host, "videohive.net") {
return strings.HasPrefix(path, "item/")
}
if strings.Contains(host, "elements.envato.com") {
if path == "" || strings.Contains(path, "/") {
return false
}
return regexp.MustCompile(`-[A-Z0-9]{6,}$`).MatchString(path)
}
return false
}
func isRenderableArtgridResult(result SearchResult) bool {
parsed, err := url.Parse(result.Link)
if err != nil {
return false
}
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 { func firstNonEmpty(values ...string) string {
for _, value := range values { for _, value := range values {
if strings.TrimSpace(value) != "" { if strings.TrimSpace(value) != "" {
@@ -225,39 +284,6 @@ func inferDisplayLink(link string, parsed []any) string {
return "" return ""
} }
func isEnvatoURL(link string) bool {
lower := strings.ToLower(link)
return strings.Contains(lower, "envato") || strings.Contains(lower, "videohive.net")
}
func isRenderableLink(link, source string) bool {
parsed, err := url.Parse(link)
if err != nil {
return false
}
path := strings.Trim(parsed.Path, "/")
if path == "" {
return false
}
lower := strings.ToLower(link)
switch source {
case "Envato":
return strings.Contains(lower, "/item/") || strings.Contains(lower, "/stock-video/") || strings.Contains(lower, "/video-templates/")
case "Artgrid":
return strings.Contains(lower, "artgrid.io") && len(strings.Split(path, "/")) >= 2
default:
return true
}
}
func buildEnvatoQuery(query string) string {
return fmt.Sprintf(`%s ("stock video" OR footage OR "video template" OR cinematic) (site:elements.envato.com/stock-video OR site:elements.envato.com/video-templates OR site:videohive.net/item)`, query)
}
func buildArtgridQuery(query string) string {
return fmt.Sprintf(`%s ("stock footage" OR "b-roll" OR cinematic OR editorial) (site:artgrid.io)`, query)
}
func deriveThumbnail(link string) string { func deriveThumbnail(link string) string {
if videoID := extractYouTubeID(link); videoID != "" { if videoID := extractYouTubeID(link); videoID != "" {
return "https://i.ytimg.com/vi/" + videoID + "/hqdefault.jpg" return "https://i.ytimg.com/vi/" + videoID + "/hqdefault.jpg"
+100 -14
View File
@@ -39,9 +39,11 @@ func NewGeminiService(apiKey string) *GeminiService {
func (g *GeminiService) ExpandQuery(query string) ([]string, error) { func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
if g.APIKey == "" { if g.APIKey == "" {
return fallbackQueryExpansion(query), nil return fallbackQueryExpansion(query, query), nil
} }
englishBase := g.TranslateQuery(query)
body := map[string]any{ body := map[string]any{
"systemInstruction": map[string]any{ "systemInstruction": map[string]any{
"parts": []map[string]string{ "parts": []map[string]string{
@@ -55,12 +57,13 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
"parts": []map[string]string{ "parts": []map[string]string{
{ {
"text": `Return JSON only in this shape: {"querywords":["..."]}. "text": `Return JSON only in this shape: {"querywords":["..."]}.
Generate at most 10 concise search variations for media discovery across Google Video, Envato, and Artgrid. Generate at most 10 concise English search variations for media discovery across Google Video, Envato, and Artgrid.
If the user query is in Korean, include strong English search variants that a stock footage editor would use. 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. 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. Avoid celebrity gossip, reaction-style phrasing, clickbait phrasing, and generic web search wording.
Mix Korean and English when useful, but make sure several queries are clean English production keywords. Do not output Korean unless it is part of a proper noun.
User query: ` + query, Original user query: ` + query + `
English base translation: ` + englishBase,
}, },
}, },
}, },
@@ -86,7 +89,7 @@ User query: ` + query,
rawText, err := g.generateText(body) rawText, err := g.generateText(body)
if err != nil { if err != nil {
return fallbackQueryExpansion(query), nil return fallbackQueryExpansion(query, englishBase), nil
} }
jsonText, err := extractJSONObject(rawText) jsonText, err := extractJSONObject(rawText)
@@ -108,8 +111,9 @@ Output must start with { and end with }.
Do not add prose, explanations, markdown, code fences, or labels. Do not add prose, explanations, markdown, code fences, or labels.
Return exactly this shape: {"querywords":["..."]}. Return exactly this shape: {"querywords":["..."]}.
Generate up to 10 search queries for media discovery across Google Video, Envato, and Artgrid. Generate up to 10 search queries for media discovery across Google Video, Envato, and Artgrid.
If the original query is Korean, include strong English stock-footage search phrases. Every query must be in natural English and suitable for stock-footage search.
User query: ` + query, Original user query: ` + query + `
English base translation: ` + englishBase,
}, },
}, },
}, },
@@ -134,20 +138,20 @@ User query: ` + query,
} }
rawText, retryErr := g.generateText(strictBody) rawText, retryErr := g.generateText(strictBody)
if retryErr != nil { if retryErr != nil {
return fallbackQueryExpansion(query), nil return fallbackQueryExpansion(query, englishBase), nil
} }
jsonText, err = extractJSONObject(rawText) jsonText, err = extractJSONObject(rawText)
if err != nil { if err != nil {
return fallbackQueryExpansion(query), nil return fallbackQueryExpansion(query, englishBase), nil
} }
} }
var parsed QueryExpansion var parsed QueryExpansion
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
return fallbackQueryExpansion(query), nil return fallbackQueryExpansion(query, englishBase), nil
} }
queries := fallbackQueryExpansion(query) queries := fallbackQueryExpansion(query, englishBase)
seen := map[string]bool{} seen := map[string]bool{}
for _, existing := range queries { for _, existing := range queries {
seen[strings.ToLower(strings.TrimSpace(existing))] = true seen[strings.ToLower(strings.TrimSpace(existing))] = true
@@ -167,6 +171,47 @@ User query: ` + query,
return queries, nil return queries, nil
} }
func (g *GeminiService) TranslateQuery(query string) string {
if strings.TrimSpace(query) == "" || looksMostlyASCII(query) || g.APIKey == "" {
return strings.TrimSpace(query)
}
body := map[string]any{
"systemInstruction": map[string]any{
"parts": []map[string]string{
{
"text": "You translate media search intents into natural English. Output one plain English search phrase only. No labels, no quotes, no explanations.",
},
},
},
"contents": []map[string]any{
{
"parts": []map[string]string{
{
"text": "Translate this user query into concise English suitable for stock-footage search: " + query,
},
},
},
},
"generationConfig": map[string]any{
"responseMimeType": "text/plain",
"temperature": 0.1,
"maxOutputTokens": 40,
},
}
rawText, err := g.generateText(body)
if err != nil {
return strings.TrimSpace(query)
}
translated := sanitizePlainEnglishLine(rawText)
if translated == "" {
return strings.TrimSpace(query)
}
return translated
}
func (g *GeminiService) generateText(body map[string]any) (string, error) { func (g *GeminiService) generateText(body map[string]any) (string, error) {
rawBody, _ := json.Marshal(body) rawBody, _ := json.Marshal(body)
endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey
@@ -402,8 +447,11 @@ func truncateForError(text string, limit int) string {
return trimmed[:limit] + "..." return trimmed[:limit] + "..."
} }
func fallbackQueryExpansion(query string) []string { func fallbackQueryExpansion(originalQuery, englishQuery string) []string {
base := strings.TrimSpace(query) base := strings.TrimSpace(englishQuery)
if base == "" {
base = strings.TrimSpace(originalQuery)
}
candidates := []string{ candidates := []string{
base, base,
base + " b-roll", base + " b-roll",
@@ -416,6 +464,9 @@ func fallbackQueryExpansion(query string) []string {
base + " 4k footage", base + " 4k footage",
base + " cinematic b-roll", base + " cinematic b-roll",
} }
if strings.TrimSpace(originalQuery) != "" && !strings.EqualFold(strings.TrimSpace(originalQuery), strings.TrimSpace(englishQuery)) {
candidates = append(candidates, strings.TrimSpace(originalQuery))
}
seen := map[string]bool{} seen := map[string]bool{}
queries := make([]string, 0, len(candidates)) queries := make([]string, 0, len(candidates))
@@ -433,3 +484,38 @@ func fallbackQueryExpansion(query string) []string {
} }
return queries return queries
} }
func sanitizePlainEnglishLine(text string) string {
lines := strings.Split(text, "\n")
for _, line := range lines {
line = strings.TrimSpace(strings.Trim(line, "\"'`"))
if line == "" {
continue
}
lower := strings.ToLower(line)
for _, prefix := range []string{"translation:", "english:", "translated query:"} {
if strings.HasPrefix(lower, prefix) {
line = strings.TrimSpace(line[len(prefix):])
lower = strings.ToLower(line)
}
}
if strings.HasPrefix(lower, "here is") || strings.HasPrefix(lower, "the translation") {
continue
}
if line != "" {
return line
}
}
return ""
}
func looksMostlyASCII(text string) bool {
ascii := 0
runes := []rune(text)
for _, r := range runes {
if r <= 127 {
ascii++
}
}
return ascii >= len(runes)*8/10
}
+7 -5
View File
@@ -70,7 +70,7 @@
</main> </main>
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4"> <div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4">
<div class="w-full max-w-4xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl"> <div class="w-full max-w-5xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div>
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Download Preview</p> <p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Download Preview</p>
@@ -78,12 +78,12 @@
</div> </div>
<button id="closePreviewModal" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button> <button id="closePreviewModal" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
</div> </div>
<div class="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_360px]"> <div class="mt-5 flex flex-col gap-5">
<div id="previewMediaFrame" class="min-w-0 flex min-h-[320px] items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black/30 p-2"> <div id="previewMediaFrame" class="min-w-0 flex min-h-[320px] items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black/30 p-2">
<video id="previewVideo" class="hidden max-h-[60vh] w-full bg-black object-contain" controls playsinline></video> <video id="previewVideo" class="hidden max-h-[60vh] w-full bg-black object-contain" controls playsinline></video>
<img id="previewThumbnail" class="max-h-[60vh] w-full object-contain" alt="" /> <img id="previewThumbnail" class="max-h-[60vh] w-full object-contain" alt="" />
</div> </div>
<div class="min-w-0 space-y-4"> <div class="min-w-0 grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4"> <div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div class="flex items-center justify-between text-sm text-zinc-400"> <div class="flex items-center justify-between text-sm text-zinc-400">
<span>Detected duration</span> <span>Detected duration</span>
@@ -116,7 +116,9 @@
<span class="text-sm text-zinc-400">Quality</span> <span class="text-sm text-zinc-400">Quality</span>
<select id="qualitySelect" class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"></select> <select id="qualitySelect" class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"></select>
</label> </label>
<button id="confirmDownload" class="w-full rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">Confirm Download</button> <div class="lg:col-span-2">
<button id="confirmDownload" class="w-full rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">Confirm Download</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -136,6 +138,6 @@
</a> </a>
</template> </template>
<script src="/app.js?v=20260313c" defer></script> <script src="/app.js?v=20260313d" defer></script>
</body> </body>
</html> </html>